Skip to content

Commit 2d2d64f

Browse files
committed
feat: add skills management feature with skills catalog and UI
- Implemented a new Skills page to display and manage skills. - Added a skills catalog query to fetch available skills from the local API. - Introduced filtering functionality for skills based on search queries. - Enhanced UI components for better user experience, including skill cards and tooltips. - Updated routing to include the new Skills page. - Added server-side support for listing skills and handling related commands. - Modified existing components to accommodate new skills-related features. - Improved session logic to handle tool work entries more effectively.
1 parent 3ac1e8a commit 2d2d64f

56 files changed

Lines changed: 1985 additions & 1080 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/server/scripts/acp-mock-agent.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,10 @@ const authMethods = (process.env.T3_ACP_AUTH_METHODS ?? "")
3434
.map((method) => method.trim())
3535
.filter((method) => method.length > 0);
3636
const sessionId = "mock-session-1";
37+
const COPILOT_AGENT_MODE_ID = "https://agentclientprotocol.com/protocol/session-modes#agent";
38+
const COPILOT_PLAN_MODE_ID = "https://agentclientprotocol.com/protocol/session-modes#plan";
3739

38-
let currentModeId = "ask";
40+
let currentModeId = COPILOT_AGENT_MODE_ID;
3941
let currentModelId = "default";
4042
let parameterizedModelPicker = false;
4143
let currentReasoning = "medium";
@@ -263,20 +265,15 @@ function configOptions(): ReadonlyArray<AcpSchema.SessionConfigOption> {
263265

264266
const availableModes: ReadonlyArray<AcpSchema.SessionMode> = [
265267
{
266-
id: "ask",
267-
name: "Ask",
268-
description: "Request permission before making any changes",
268+
id: COPILOT_AGENT_MODE_ID,
269+
name: "Agent",
270+
description: "Write and modify code with tool access",
269271
},
270272
{
271-
id: "architect",
272-
name: "Architect",
273+
id: COPILOT_PLAN_MODE_ID,
274+
name: "Plan",
273275
description: "Design and plan software systems without implementation",
274276
},
275-
{
276-
id: "code",
277-
name: "Code",
278-
description: "Write and modify code with full tool access",
279-
},
280277
];
281278

282279
function modeState(): AcpSchema.SessionModeState {

apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2401,6 +2401,111 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-latest-turn-ses
24012401
assert.deepEqual(rows, [{ latestTurnId: "turn-session-stop" }]);
24022402
}),
24032403
);
2404+
2405+
it.effect("preserves session resume cursor when later lifecycle updates omit it", () =>
2406+
Effect.gen(function* () {
2407+
const projectionPipeline = yield* OrchestrationProjectionPipeline;
2408+
const eventStore = yield* OrchestrationEventStore;
2409+
const sql = yield* SqlClient.SqlClient;
2410+
const threadId = ThreadId.make("thread-resume-cursor-preserve");
2411+
const resumeCursor = {
2412+
schemaVersion: 1,
2413+
sessionId: "cf7b03af-afea-47af-8fc1-871efeb11b23",
2414+
};
2415+
2416+
const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
2417+
eventStore
2418+
.append(event)
2419+
.pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
2420+
2421+
yield* appendAndProject({
2422+
type: "thread.created",
2423+
eventId: EventId.make("evt-resume-cursor-created"),
2424+
aggregateKind: "thread",
2425+
aggregateId: threadId,
2426+
occurredAt: "2026-05-08T18:33:12.000Z",
2427+
commandId: CommandId.make("cmd-resume-cursor-created"),
2428+
causationEventId: null,
2429+
correlationId: CorrelationId.make("cmd-resume-cursor-created"),
2430+
metadata: {},
2431+
payload: {
2432+
threadId,
2433+
projectId: ProjectId.make("project-resume-cursor"),
2434+
title: "Resume cursor",
2435+
modelSelection: {
2436+
instanceId: ProviderInstanceId.make("copilot"),
2437+
model: "auto",
2438+
},
2439+
runtimeMode: "full-access",
2440+
interactionMode: "default",
2441+
pendingRuntimeMode: null,
2442+
branch: null,
2443+
worktreePath: null,
2444+
createdAt: "2026-05-08T18:33:12.000Z",
2445+
updatedAt: "2026-05-08T18:33:12.000Z",
2446+
},
2447+
});
2448+
2449+
yield* appendAndProject({
2450+
type: "thread.session-set",
2451+
eventId: EventId.make("evt-resume-cursor-initial"),
2452+
aggregateKind: "thread",
2453+
aggregateId: threadId,
2454+
occurredAt: "2026-05-08T18:33:15.000Z",
2455+
commandId: CommandId.make("cmd-resume-cursor-initial"),
2456+
causationEventId: null,
2457+
correlationId: CorrelationId.make("cmd-resume-cursor-initial"),
2458+
metadata: {},
2459+
payload: {
2460+
threadId,
2461+
session: {
2462+
threadId,
2463+
status: "ready",
2464+
providerName: "copilot",
2465+
providerInstanceId: ProviderInstanceId.make("copilot"),
2466+
runtimeMode: "full-access",
2467+
activeTurnId: null,
2468+
resumeCursor,
2469+
lastError: null,
2470+
updatedAt: "2026-05-08T18:33:15.000Z",
2471+
},
2472+
},
2473+
});
2474+
2475+
yield* appendAndProject({
2476+
type: "thread.session-set",
2477+
eventId: EventId.make("evt-resume-cursor-lifecycle"),
2478+
aggregateKind: "thread",
2479+
aggregateId: threadId,
2480+
occurredAt: "2026-05-08T18:33:16.000Z",
2481+
commandId: CommandId.make("cmd-resume-cursor-lifecycle"),
2482+
causationEventId: null,
2483+
correlationId: CorrelationId.make("cmd-resume-cursor-lifecycle"),
2484+
metadata: {},
2485+
payload: {
2486+
threadId,
2487+
session: {
2488+
threadId,
2489+
status: "running",
2490+
providerName: "copilot",
2491+
providerInstanceId: ProviderInstanceId.make("copilot"),
2492+
runtimeMode: "full-access",
2493+
activeTurnId: TurnId.make("turn-resume-cursor"),
2494+
lastError: null,
2495+
updatedAt: "2026-05-08T18:33:16.000Z",
2496+
},
2497+
},
2498+
});
2499+
2500+
const rows = yield* sql<{ readonly resumeCursor: string | null }>`
2501+
SELECT resume_cursor_json AS "resumeCursor"
2502+
FROM projection_thread_sessions
2503+
WHERE thread_id = ${threadId}
2504+
`;
2505+
2506+
assert.deepEqual(JSON.parse(rows[0]?.resumeCursor ?? "null"), resumeCursor);
2507+
}),
2508+
);
24042509
},
24052510
);
24062511

apps/server/src/orchestration/Layers/ProjectionPipeline.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export const ORCHESTRATION_PROJECTOR_NAMES = {
5555
threadMessages: "projection.thread-messages",
5656
threadProposedPlans: "projection.thread-proposed-plans",
5757
threadActivities: "projection.thread-activities",
58-
threadSessions: "projection.thread-sessions",
58+
threadSessions: "projection.thread-sessions.v2",
5959
threadTurns: "projection.thread-turns",
6060
checkpoints: "projection.checkpoints",
6161
pendingApprovals: "projection.pending-approvals",
@@ -958,14 +958,23 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
958958
if (event.type !== "thread.session-set") {
959959
return;
960960
}
961+
const existingSession = yield* projectionThreadSessionRepository.getByThreadId({
962+
threadId: event.payload.threadId,
963+
});
964+
const resumeCursor = Object.hasOwn(event.payload.session, "resumeCursor")
965+
? (event.payload.session.resumeCursor ?? null)
966+
: Option.match(existingSession, {
967+
onNone: () => null,
968+
onSome: (session) => session.resumeCursor,
969+
});
961970
yield* projectionThreadSessionRepository.upsert({
962971
threadId: event.payload.threadId,
963972
status: event.payload.session.status,
964973
providerName: event.payload.session.providerName,
965974
providerInstanceId: event.payload.session.providerInstanceId ?? null,
966975
runtimeMode: event.payload.session.runtimeMode,
967976
activeTurnId: event.payload.session.activeTurnId,
968-
resumeCursor: event.payload.session.resumeCursor ?? null,
977+
resumeCursor,
969978
lastError: event.payload.session.lastError,
970979
updatedAt: event.payload.session.updatedAt,
971980
});

apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
405405
},
406406
interactionMode: "default",
407407
runtimeMode: "full-access",
408+
pendingRuntimeMode: null,
408409
branch: null,
409410
worktreePath: null,
410411
latestTurn: {

apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1129,6 +1129,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
11291129
title: row.title,
11301130
modelSelection: row.modelSelection,
11311131
runtimeMode: row.runtimeMode,
1132+
pendingRuntimeMode: row.pendingRuntimeMode,
11321133
interactionMode: row.interactionMode,
11331134
branch: row.branch,
11341135
worktreePath: row.worktreePath,
@@ -1332,6 +1333,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
13321333
title: threadRow.value.title,
13331334
modelSelection: threadRow.value.modelSelection,
13341335
runtimeMode: threadRow.value.runtimeMode,
1336+
pendingRuntimeMode: threadRow.value.pendingRuntimeMode,
13351337
interactionMode: threadRow.value.interactionMode,
13361338
branch: threadRow.value.branch,
13371339
worktreePath: threadRow.value.worktreePath,

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,83 @@ describe("ProviderRuntimeIngestion", () => {
706706
expect(message?.streaming).toBe(false);
707707
});
708708

709+
it("scopes Copilot assistant segment item ids by turn", async () => {
710+
const harness = await createHarness();
711+
const reusedItemId = asItemId("assistant:copilot-session:segment:9");
712+
const firstCreatedAt = "2026-05-09T01:14:12.000Z";
713+
const secondCreatedAt = "2026-05-09T01:33:57.000Z";
714+
715+
harness.emit({
716+
type: "content.delta",
717+
eventId: asEventId("evt-reused-assistant-item-first-delta"),
718+
provider: ProviderDriverKind.make("copilot"),
719+
createdAt: firstCreatedAt,
720+
threadId: asThreadId("thread-1"),
721+
turnId: asTurnId("turn-reused-item-first"),
722+
itemId: reusedItemId,
723+
payload: {
724+
streamKind: "assistant_text",
725+
delta: "old response",
726+
},
727+
});
728+
harness.emit({
729+
type: "item.completed",
730+
eventId: asEventId("evt-reused-assistant-item-first-complete"),
731+
provider: ProviderDriverKind.make("copilot"),
732+
createdAt: firstCreatedAt,
733+
threadId: asThreadId("thread-1"),
734+
turnId: asTurnId("turn-reused-item-first"),
735+
itemId: reusedItemId,
736+
payload: {
737+
itemType: "assistant_message",
738+
status: "completed",
739+
},
740+
});
741+
harness.emit({
742+
type: "content.delta",
743+
eventId: asEventId("evt-reused-assistant-item-second-delta"),
744+
provider: ProviderDriverKind.make("copilot"),
745+
createdAt: secondCreatedAt,
746+
threadId: asThreadId("thread-1"),
747+
turnId: asTurnId("turn-reused-item-second"),
748+
itemId: reusedItemId,
749+
payload: {
750+
streamKind: "assistant_text",
751+
delta: "new response",
752+
},
753+
});
754+
harness.emit({
755+
type: "item.completed",
756+
eventId: asEventId("evt-reused-assistant-item-second-complete"),
757+
provider: ProviderDriverKind.make("copilot"),
758+
createdAt: secondCreatedAt,
759+
threadId: asThreadId("thread-1"),
760+
turnId: asTurnId("turn-reused-item-second"),
761+
itemId: reusedItemId,
762+
payload: {
763+
itemType: "assistant_message",
764+
status: "completed",
765+
},
766+
});
767+
768+
const thread = await waitForThread(
769+
harness.engine,
770+
(entry) =>
771+
entry.messages.filter((message) => message.id.includes("assistant:copilot-session"))
772+
.length === 2,
773+
);
774+
const messages = thread.messages
775+
.filter((message) => message.id.includes("assistant:copilot-session"))
776+
.toSorted((left, right) => left.createdAt.localeCompare(right.createdAt));
777+
778+
expect(messages.map((message) => message.turnId)).toEqual([
779+
"turn-reused-item-first",
780+
"turn-reused-item-second",
781+
]);
782+
expect(messages.map((message) => message.text)).toEqual(["old response", "new response"]);
783+
expect(messages.map((message) => message.createdAt)).toEqual([firstCreatedAt, secondCreatedAt]);
784+
});
785+
709786
it("uses assistant item completion detail when no assistant deltas were streamed", async () => {
710787
const harness = await createHarness();
711788
const now = new Date().toISOString();

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,11 @@ function proposedPlanIdFromEvent(event: ProviderRuntimeEvent, threadId: ThreadId
114114
}
115115

116116
function assistantSegmentBaseKeyFromEvent(event: ProviderRuntimeEvent): string {
117-
return String(event.itemId ?? event.turnId ?? event.eventId);
117+
const baseKey = String(event.itemId ?? event.turnId ?? event.eventId);
118+
if (event.turnId !== undefined && baseKey.startsWith("assistant:")) {
119+
return `turn:${event.turnId}:item:${baseKey}`;
120+
}
121+
return baseKey;
118122
}
119123

120124
function assistantSegmentMessageId(baseKey: string, segmentIndex: number): MessageId {
@@ -1406,9 +1410,7 @@ const make = Effect.gen(function* () {
14061410
const assistantCompletion =
14071411
event.type === "item.completed" && event.payload.itemType === "assistant_message"
14081412
? {
1409-
messageId: MessageId.make(
1410-
`assistant:${event.itemId ?? event.turnId ?? event.eventId}`,
1411-
),
1413+
messageId: assistantSegmentMessageId(assistantSegmentBaseKeyFromEvent(event), 0),
14121414
fallbackText: event.payload.detail,
14131415
}
14141416
: undefined;

apps/server/src/provider/Layers/CodexSessionRuntime.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { randomUUID } from "node:crypto";
2+
13
import {
24
ApprovalRequestId,
35
DEFAULT_MODEL,

0 commit comments

Comments
 (0)