diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index c5eb125ab..642f9b3de 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -205,7 +205,7 @@ export interface OrchestrationIntegrationHarness { } interface MakeOrchestrationIntegrationHarnessOptions { - readonly provider?: "codex"; + readonly provider?: "codex" | "claudeCode"; readonly realCodex?: boolean; } diff --git a/apps/server/integration/TestProviderAdapter.integration.ts b/apps/server/integration/TestProviderAdapter.integration.ts index 017c59e2c..98ad7b8b5 100644 --- a/apps/server/integration/TestProviderAdapter.integration.ts +++ b/apps/server/integration/TestProviderAdapter.integration.ts @@ -35,7 +35,7 @@ export interface TestTurnResponse { export type FixtureProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: "codex"; + readonly provider: "codex" | "claudeCode"; readonly createdAt: string; readonly threadId: string; readonly turnId?: string | undefined; @@ -177,7 +177,7 @@ function normalizeFixtureEvent(rawEvent: Record): ProviderRunti export interface TestProviderAdapterHarness { readonly adapter: ProviderAdapterShape; - readonly provider: "codex"; + readonly provider: "codex" | "claudeCode"; readonly queueTurnResponse: ( threadId: ThreadId, response: TestTurnResponse, @@ -197,7 +197,7 @@ export interface TestProviderAdapterHarness { } interface MakeTestProviderAdapterHarnessOptions { - readonly provider?: "codex"; + readonly provider?: "codex" | "claudeCode"; } function nowIso(): string { @@ -205,7 +205,7 @@ function nowIso(): string { } function sessionNotFound( - provider: "codex", + provider: "codex" | "claudeCode", threadId: ThreadId, ): ProviderAdapterSessionNotFoundError { return new ProviderAdapterSessionNotFoundError({ @@ -215,7 +215,7 @@ function sessionNotFound( } function missingSessionEffect( - provider: "codex", + provider: "codex" | "claudeCode", threadId: ThreadId, ): Effect.Effect { return Effect.fail(sessionNotFound(provider, threadId)); diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index 42dcfe34f..dd3227394 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -36,7 +36,7 @@ const PROJECT_ID = asProjectId("project-1"); const THREAD_ID = ThreadId.makeUnsafe("thread-1"); const FIXTURE_TURN_ID = "fixture-turn"; const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1"); -type IntegrationProvider = "codex"; +type IntegrationProvider = "codex" | "claudeCode"; function nowIso() { return new Date().toISOString(); @@ -137,6 +137,7 @@ const startTurn = (input: { readonly messageId: string; readonly text: string; readonly provider?: IntegrationProvider; + readonly model?: string; }) => input.harness.engine.dispatch({ type: "thread.turn.start", @@ -149,6 +150,7 @@ const startTurn = (input: { attachments: [], }, ...(input.provider !== undefined ? { provider: input.provider } : {}), + ...(input.model !== undefined ? { model: input.model } : {}), interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: nowIso(), @@ -846,6 +848,412 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git ), ); +it.live("starts a claudeCode session on first turn when provider is requested", () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-start-1", "2026-02-24T10:10:00.000Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-start-2", "2026-02-24T10:10:00.050Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Claude first turn.\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-start-3", "2026-02-24T10:10:00.100Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-initial", + messageId: "msg-user-claude-initial", + text: "Use Claude", + provider: "claudeCode", + model: "claude-sonnet-4-6", + }); + + const thread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.session?.providerName === "claudeCode" && + entry.session.status === "ready" && + entry.messages.some( + (message) => message.role === "assistant" && message.text === "Claude first turn.\n", + ), + ); + assert.equal(thread.session?.providerName, "claudeCode"); + }), + "claudeCode", + ), +); + +it.live("recovers claudeCode sessions after adapter stopAll using persisted resume state", () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-recover-1", "2026-02-24T10:11:00.000Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-recover-2", "2026-02-24T10:11:00.050Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Turn before restart.\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-recover-3", "2026-02-24T10:11:00.100Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-recover-1", + messageId: "msg-user-claude-recover-1", + text: "Before restart", + provider: "claudeCode", + model: "claude-sonnet-4-6", + }); + + yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.latestTurn?.turnId === "turn-1" && entry.session?.threadId === "thread-1", + ); + + yield* harness.adapterHarness!.adapter.stopAll(); + yield* waitForSync( + () => harness.adapterHarness!.listActiveSessionIds(), + (sessionIds) => sessionIds.length === 0, + "claude adapter stopAll", + ); + + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-recover-4", "2026-02-24T10:11:01.000Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-recover-5", "2026-02-24T10:11:01.050Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Turn after restart.\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-recover-6", "2026-02-24T10:11:01.100Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-recover-2", + messageId: "msg-user-claude-recover-2", + text: "After restart", + model: "claude-sonnet-4-6", + }); + + yield* waitForSync( + () => harness.adapterHarness!.getStartCount(), + (count) => count === 2, + "claude provider recovery start", + ); + + const recoveredThread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.session?.providerName === "claudeCode" && + entry.messages.some( + (message) => message.role === "user" && message.text === "After restart", + ), + ); + assert.equal(recoveredThread.session?.providerName, "claudeCode"); + assert.equal(recoveredThread.session?.threadId, "thread-1"); + }), + "claudeCode", + ), +); + +it.live("forwards claudeCode approval responses to the provider session", () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-approval-1", "2026-02-24T10:12:00.000Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "approval.requested", + ...runtimeBase("evt-claude-approval-2", "2026-02-24T10:12:00.050Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + requestId: APPROVAL_REQUEST_ID, + requestKind: "command", + detail: "Approve Claude tool call", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-approval-3", "2026-02-24T10:12:00.100Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-approval", + messageId: "msg-user-claude-approval", + text: "Need approval", + provider: "claudeCode", + model: "claude-sonnet-4-6", + }); + + yield* harness.waitForThread(THREAD_ID, (entry) => + entry.activities.some((activity) => activity.kind === "approval.requested"), + ); + + yield* harness.engine.dispatch({ + type: "thread.approval.respond", + commandId: CommandId.makeUnsafe("cmd-claude-approval-respond"), + threadId: THREAD_ID, + requestId: APPROVAL_REQUEST_ID, + decision: "accept", + createdAt: nowIso(), + }); + + const approvalResponses = yield* waitForSync( + () => harness.adapterHarness!.getApprovalResponses(THREAD_ID), + (responses) => responses.length === 1, + "claude provider approval response", + ); + assert.equal(approvalResponses[0]?.decision, "accept"); + }), + "claudeCode", + ), +); + +it.live("forwards thread.turn.interrupt to claudeCode provider sessions", () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-interrupt-1", "2026-02-24T10:13:00.000Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-interrupt-2", "2026-02-24T10:13:00.050Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Long running output.\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-interrupt-3", "2026-02-24T10:13:00.100Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-interrupt", + messageId: "msg-user-claude-interrupt", + text: "Start long turn", + provider: "claudeCode", + model: "claude-sonnet-4-6", + }); + + yield* harness.waitForThread(THREAD_ID, (entry) => entry.session?.threadId === "thread-1"); + + yield* harness.engine.dispatch({ + type: "thread.turn.interrupt", + commandId: CommandId.makeUnsafe("cmd-turn-interrupt-claude"), + threadId: THREAD_ID, + createdAt: nowIso(), + }); + yield* harness.waitForDomainEvent( + (event) => event.type === "thread.turn-interrupt-requested", + ); + + const interruptCalls = yield* waitForSync( + () => harness.adapterHarness!.getInterruptCalls(THREAD_ID), + (calls) => calls.length === 1, + "claude provider interrupt call", + ); + assert.equal(interruptCalls.length, 1); + }), + "claudeCode", + ), +); + +it.live("reverts claudeCode turns and rolls back provider conversation state", () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-revert-1", "2026-02-24T10:14:00.000Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-revert-2", "2026-02-24T10:14:00.050Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "README -> v2\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-revert-3", "2026-02-24T10:14:00.100Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + mutateWorkspace: ({ cwd }) => + Effect.sync(() => { + fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); + }), + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-revert-1", + messageId: "msg-user-claude-revert-1", + text: "First Claude edit", + provider: "claudeCode", + model: "claude-sonnet-4-6", + }); + + yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.latestTurn?.turnId === "turn-1" && entry.session?.threadId === "thread-1", + ); + + yield* harness.adapterHarness!.queueTurnResponse(THREAD_ID, { + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-revert-4", "2026-02-24T10:14:01.000Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-revert-5", "2026-02-24T10:14:01.050Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "README -> v3\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-revert-6", "2026-02-24T10:14:01.100Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + mutateWorkspace: ({ cwd }) => + Effect.sync(() => { + fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); + }), + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-revert-2", + messageId: "msg-user-claude-revert-2", + text: "Second Claude edit", + model: "claude-sonnet-4-6", + }); + + yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.latestTurn?.turnId === "turn-2" && + entry.checkpoints.length === 2 && + entry.session?.providerName === "claudeCode", + ); + + yield* harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: CommandId.makeUnsafe("cmd-checkpoint-revert-claude"), + threadId: THREAD_ID, + turnCount: 1, + createdAt: nowIso(), + }); + + const revertedThread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.checkpoints.length === 1 && entry.checkpoints[0]?.checkpointTurnCount === 1, + ); + assert.equal(revertedThread.checkpoints[0]?.checkpointTurnCount, 1); + assert.deepEqual(harness.adapterHarness!.getRollbackCalls(THREAD_ID), [1]); + }), + "claudeCode", + ), +); + it.live( "appends checkpoint.revert.failed activity when revert is requested without an active session", () => diff --git a/apps/server/package.json b/apps/server/package.json index a6ffd53b8..7783cacc0 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -22,6 +22,7 @@ "test": "vitest run" }, "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.62", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@pierre/diffs": "^1.1.0-beta.16", diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 09773b71d..1ae40a464 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -44,7 +44,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: "codex"; + readonly provider: "codex" | "claudeCode"; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; @@ -235,6 +235,7 @@ describe("CheckpointReactor", () => { readonly projectWorkspaceRoot?: string; readonly threadWorktreePath?: string | null; readonly providerSessionCwd?: string; + readonly providerName?: "codex" | "claudeCode"; }) { const cwd = createGitRepository(); tempDirs.push(cwd); @@ -242,7 +243,7 @@ describe("CheckpointReactor", () => { cwd, options?.hasSession ?? true, options?.providerSessionCwd ?? cwd, - "codex", + options?.providerName ?? "codex", ); const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionPipelineLive), @@ -403,6 +404,67 @@ describe("CheckpointReactor", () => { ).toBe("v2\n"); }); + it("captures pre-turn and completion checkpoints for claudeCode runtime events", async () => { + const harness = await createHarness({ + seedFilesystemCheckpoints: false, + providerName: "claudeCode", + }); + const createdAt = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-capture-claude"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "ready", + providerName: "claudeCode", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }), + ); + + harness.provider.emit({ + type: "turn.started", + eventId: EventId.makeUnsafe("evt-turn-started-claude-1"), + provider: "claudeCode", + createdAt: new Date().toISOString(), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-claude-1"), + }); + await waitForGitRefExists( + harness.cwd, + checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), + ); + + fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.makeUnsafe("evt-turn-completed-claude-1"), + provider: "claudeCode", + createdAt: new Date().toISOString(), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-claude-1"), + payload: { state: "completed" }, + }); + + await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); + const thread = await waitForThread( + harness.engine, + (entry) => entry.latestTurn?.turnId === "turn-claude-1" && entry.checkpoints.length === 1, + ); + + expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); + expect( + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1)), + ).toBe(true); + }); + it("ignores auxiliary thread turn completion while primary turn is active", async () => { const harness = await createHarness({ seedFilesystemCheckpoints: false }); const createdAt = new Date().toISOString(); @@ -792,6 +854,75 @@ describe("CheckpointReactor", () => { ).toBe(false); }); + it("executes provider revert and emits thread.reverted for claudeCode sessions", async () => { + const harness = await createHarness({ providerName: "claudeCode" }); + const createdAt = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-claude"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "ready", + providerName: "claudeCode", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.makeUnsafe("cmd-diff-claude-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-claude-1"), + completedAt: createdAt, + checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), + status: "ready", + files: [], + checkpointTurnCount: 1, + createdAt, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.makeUnsafe("cmd-diff-claude-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-claude-2"), + completedAt: createdAt, + checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2), + status: "ready", + files: [], + checkpointTurnCount: 2, + createdAt, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: CommandId.makeUnsafe("cmd-revert-request-claude"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnCount: 1, + createdAt, + }), + ); + + await waitForEvent(harness.engine, (event) => event.type === "thread.reverted"); + expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); + expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ + threadId: ThreadId.makeUnsafe("thread-1"), + numTurns: 1, + }); + }); + it("processes consecutive revert requests with deterministic rollback sequencing", async () => { const harness = await createHarness(); const createdAt = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 8de44d78f..00d1d4dcd 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -96,7 +96,7 @@ describe("ProviderCommandReactor", () => { typeof input === "object" && input !== null && "provider" in input && - input.provider === "codex" + (input.provider === "codex" || input.provider === "claudeCode") ? input.provider : "codex"; const resumeCursor = @@ -351,6 +351,56 @@ describe("ProviderCommandReactor", () => { }); }); + it("forwards claude effort options through session start and turn send", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-claude-effort"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-claude-effort"), + role: "user", + text: "hello with effort", + attachments: [], + }, + provider: "claudeCode", + model: "claude-sonnet-4-6", + modelOptions: { + claudeCode: { + effort: "max", + }, + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + provider: "claudeCode", + model: "claude-sonnet-4-6", + modelOptions: { + claudeCode: { + effort: "max", + }, + }, + }); + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ + threadId: ThreadId.makeUnsafe("thread-1"), + model: "claude-sonnet-4-6", + modelOptions: { + claudeCode: { + effort: "max", + }, + }, + }); + }); + it("forwards plan interaction mode to the provider turn request", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -435,6 +485,262 @@ describe("ProviderCommandReactor", () => { expect(harness.stopSession.mock.calls.length).toBe(0); }); + it("reuses the same Claude session across turns when provider options are unchanged", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-claude-reuse-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-claude-reuse-1"), + role: "user", + text: "first claude team turn", + attachments: [], + }, + provider: "claudeCode", + model: "claude-sonnet-4-6", + providerOptions: { + claudeCode: { + experimentalAgentTeams: true, + agentProgressSummaries: true, + }, + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-claude-reuse-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-claude-reuse-2"), + role: "user", + text: "second claude team turn", + attachments: [], + }, + provider: "claudeCode", + model: "claude-sonnet-4-6", + providerOptions: { + claudeCode: { + experimentalAgentTeams: true, + agentProgressSummaries: true, + }, + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + expect(harness.startSession.mock.calls.length).toBe(1); + expect(harness.stopSession.mock.calls.length).toBe(0); + }); + + it("restarts existing claude threads on the claude provider when a session restart is needed", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-claude"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "ready", + providerName: "claudeCode", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.makeUnsafe("cmd-runtime-mode-set-claude"), + threadId: ThreadId.makeUnsafe("thread-1"), + runtimeMode: "full-access", + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-claude-existing"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-claude-existing"), + role: "user", + text: "restart with claude", + attachments: [], + }, + model: "claude-sonnet-4-6", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + provider: "claudeCode", + runtimeMode: "full-access", + }); + }); + + it("restarts claude sessions when claude effort changes", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-claude-effort-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-claude-effort-1"), + role: "user", + text: "first claude turn", + attachments: [], + }, + provider: "claudeCode", + model: "claude-sonnet-4-6", + modelOptions: { + claudeCode: { + effort: "medium", + }, + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-claude-effort-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-claude-effort-2"), + role: "user", + text: "second claude turn", + attachments: [], + }, + provider: "claudeCode", + model: "claude-sonnet-4-6", + modelOptions: { + claudeCode: { + effort: "max", + }, + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 2); + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ + provider: "claudeCode", + modelOptions: { + claudeCode: { + effort: "max", + }, + }, + }); + }); + + it("restarts claude sessions when Claude provider options change", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-claude-teams-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-claude-teams-1"), + role: "user", + text: "first claude team turn", + attachments: [], + }, + provider: "claudeCode", + model: "claude-sonnet-4-6", + providerOptions: { + claudeCode: { + experimentalAgentTeams: false, + }, + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-claude-teams-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-claude-teams-2"), + role: "user", + text: "second claude team turn", + attachments: [], + }, + provider: "claudeCode", + model: "claude-sonnet-4-6", + providerOptions: { + claudeCode: { + experimentalAgentTeams: true, + agentProgressSummaries: true, + }, + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 2); + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ + provider: "claudeCode", + providerOptions: { + claudeCode: { + experimentalAgentTeams: true, + agentProgressSummaries: true, + }, + }, + }); + }); + it("restarts the provider session when runtime mode is updated on the thread", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index fe0218845..30659ffc1 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -74,6 +74,16 @@ const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; const WORKTREE_BRANCH_PREFIX = "t3code"; const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); +const sameModelOptions = ( + left: ProviderModelOptions | undefined, + right: ProviderModelOptions | undefined, +): boolean => JSON.stringify(left ?? null) === JSON.stringify(right ?? null); + +const sameProviderOptions = ( + left: ProviderStartOptions | undefined, + right: ProviderStartOptions | undefined, +): boolean => JSON.stringify(left ?? null) === JSON.stringify(right ?? null); + function isUnknownPendingApprovalRequestError(cause: Cause.Cause): boolean { const error = Cause.squash(cause); if (Schema.is(ProviderAdapterRequestError)(error)) { @@ -136,6 +146,7 @@ const make = Effect.gen(function* () { ); const threadProviderOptions = new Map(); + const threadModelOptions = new Map(); const appendProviderFailureActivity = (input: { readonly threadId: ThreadId; @@ -206,7 +217,9 @@ const make = Effect.gen(function* () { const desiredRuntimeMode = thread.runtimeMode; const currentProvider: ProviderKind | undefined = - thread.session?.providerName === "codex" ? thread.session.providerName : undefined; + thread.session?.providerName === "codex" || thread.session?.providerName === "claudeCode" + ? thread.session.providerName + : undefined; const preferredProvider: ProviderKind | undefined = options?.provider ?? currentProvider; const desiredModel = options?.model ?? thread.model; const effectiveCwd = resolveThreadWorkspaceCwd({ @@ -267,13 +280,32 @@ const make = Effect.gen(function* () { : (yield* providerService.getCapabilities(currentProvider)).sessionModelSwitch; const modelChanged = options?.model !== undefined && options.model !== activeSession?.model; const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "restart-session"; + const previousModelOptions = threadModelOptions.get(threadId); + const shouldRestartForModelOptionsChange = + currentProvider === "claudeCode" && + options?.modelOptions !== undefined && + !sameModelOptions(previousModelOptions, options.modelOptions); + const previousProviderOptions = threadProviderOptions.get(threadId); + const shouldRestartForProviderOptionsChange = + currentProvider === "claudeCode" && + options?.providerOptions !== undefined && + !sameProviderOptions(previousProviderOptions, options.providerOptions); - if (!runtimeModeChanged && !providerChanged && !shouldRestartForModelChange) { + if ( + !runtimeModeChanged && + !providerChanged && + !shouldRestartForModelChange && + !shouldRestartForModelOptionsChange && + !shouldRestartForProviderOptionsChange + ) { return existingSessionThreadId; } const resumeCursor = - providerChanged || shouldRestartForModelChange + providerChanged || + shouldRestartForModelChange || + shouldRestartForModelOptionsChange || + shouldRestartForProviderOptionsChange ? undefined : (activeSession?.resumeCursor ?? undefined); yield* Effect.logInfo("provider command reactor restarting provider session", { @@ -287,6 +319,8 @@ const make = Effect.gen(function* () { providerChanged, modelChanged, shouldRestartForModelChange, + shouldRestartForModelOptionsChange, + shouldRestartForProviderOptionsChange, hasResumeCursor: resumeCursor !== undefined, }); const restartedSession = yield* startProviderSession({ @@ -326,15 +360,18 @@ const make = Effect.gen(function* () { if (!thread) { return; } - if (input.providerOptions !== undefined) { - threadProviderOptions.set(input.threadId, input.providerOptions); - } yield* ensureSessionForThread(input.threadId, input.createdAt, { ...(input.provider !== undefined ? { provider: input.provider } : {}), ...(input.model !== undefined ? { model: input.model } : {}), ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), }); + if (input.providerOptions !== undefined) { + threadProviderOptions.set(input.threadId, input.providerOptions); + } + if (input.modelOptions !== undefined) { + threadModelOptions.set(input.threadId, input.modelOptions); + } const normalizedInput = toNonEmptyProviderInput(input.messageText); const normalizedAttachments = input.attachments ?? []; const activeSession = yield* providerService @@ -624,13 +661,13 @@ const make = Effect.gen(function* () { return; } const cachedProviderOptions = threadProviderOptions.get(event.payload.threadId); - yield* ensureSessionForThread( - event.payload.threadId, - event.occurredAt, - cachedProviderOptions !== undefined + const cachedModelOptions = threadModelOptions.get(event.payload.threadId); + yield* ensureSessionForThread(event.payload.threadId, event.occurredAt, { + ...(cachedProviderOptions !== undefined ? { providerOptions: cachedProviderOptions } - : undefined, - ); + : {}), + ...(cachedModelOptions !== undefined ? { modelOptions: cachedModelOptions } : {}), + }); return; } case "thread.turn-start-requested": diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index b6b48c7ed..6e7a6a465 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -45,7 +45,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: "codex"; + readonly provider: "codex" | "claudeCode"; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; @@ -210,6 +210,7 @@ describe("ProviderRuntimeIngestion", () => { engine, emit: provider.emit, drain, + workspaceRoot, }; } @@ -566,6 +567,421 @@ describe("ProviderRuntimeIngestion", () => { expect(message?.streaming).toBe(false); }); + it("upserts claude reasoning deltas into a single thinking activity", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-seed-claude"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "running", + providerName: "claudeCode", + runtimeMode: "approval-required", + activeTurnId: asTurnId("turn-thinking"), + updatedAt: now, + lastError: null, + }, + createdAt: now, + }), + ); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-thinking-delta-1"), + provider: "claudeCode", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-thinking"), + itemId: asItemId("item-thinking"), + payload: { + streamKind: "reasoning_text", + delta: "Inspecting the provider state.", + }, + }); + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-thinking-delta-2"), + provider: "claudeCode", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-thinking"), + itemId: asItemId("item-thinking"), + payload: { + streamKind: "reasoning_summary_text", + delta: " Verifying restart routing.", + }, + }); + + const thread = await waitForThread(harness.engine, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => + activity.id === "thinking:thread-1:turn:turn-thinking" && + typeof (activity.payload as { detail?: unknown } | null)?.detail === "string" && + (activity.payload as { detail: string }).detail.includes("Verifying restart routing."), + ), + ); + const reasoningActivities = thread.activities.filter( + (activity: ProviderRuntimeTestActivity) => + activity.id === "thinking:thread-1:turn:turn-thinking", + ); + + expect(reasoningActivities).toHaveLength(1); + expect(reasoningActivities[0]).toMatchObject({ + tone: "thinking", + kind: "reasoning.trace", + summary: "Thinking", + turnId: "turn-thinking", + payload: { + detail: "Inspecting the provider state. Verifying restart routing.", + streamKind: "reasoning_summary_text", + }, + }); + }); + + it("projects Claude teammate lifecycle into stable team activities", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "task.started", + eventId: asEventId("evt-team-task-started"), + provider: "claudeCode", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-team"), + payload: { + taskId: "task-team-1", + description: "Review the migration plan", + toolUseId: "tool-task-1", + teammateName: "db-reviewer", + teamName: "release-squad", + agentType: "code-reviewer", + }, + }); + + harness.emit({ + type: "task.progress", + eventId: asEventId("evt-team-task-progress"), + provider: "claudeCode", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-team"), + payload: { + taskId: "task-team-1", + description: "Reviewing rollback flow", + summary: "Checking migration rollback safety.", + toolUseId: "tool-task-1", + teammateName: "db-reviewer", + teamName: "release-squad", + agentType: "code-reviewer", + }, + }); + + harness.emit({ + type: "hook.started", + eventId: asEventId("evt-team-hook-idle"), + provider: "claudeCode", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-team"), + payload: { + hookId: "hook-team-idle", + hookName: "Team idle hook", + hookEvent: "TeammateIdle", + teammateName: "db-reviewer", + teamName: "release-squad", + }, + }); + + harness.emit({ + type: "task.completed", + eventId: asEventId("evt-team-task-completed"), + provider: "claudeCode", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-team"), + payload: { + taskId: "task-team-1", + status: "completed", + summary: "Migration review finished.", + toolUseId: "tool-task-1", + teammateName: "db-reviewer", + teamName: "release-squad", + agentType: "code-reviewer", + }, + }); + + const thread = await waitForThread(harness.engine, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.kind === "teammate.completed", + ), + ); + + expect(thread.activities).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "teammate.started", + summary: "db-reviewer started", + payload: expect.objectContaining({ + taskId: "task-team-1", + toolUseId: "tool-task-1", + teammateName: "db-reviewer", + teamName: "release-squad", + }), + }), + expect.objectContaining({ + kind: "teammate.progress", + summary: "db-reviewer update", + }), + expect.objectContaining({ + kind: "teammate.idle", + summary: "Teammate idle", + payload: expect.objectContaining({ + hookEvent: "TeammateIdle", + }), + }), + expect.objectContaining({ + kind: "teammate.completed", + summary: "db-reviewer completed", + }), + ]), + ); + }); + + it("prefers Claude artifact teammate names over opaque runtime ids", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + const teamsDir = path.join(harness.workspaceRoot, ".claude", "teams"); + const tasksDir = path.join(harness.workspaceRoot, ".claude", "tasks"); + fs.mkdirSync(teamsDir, { recursive: true }); + fs.mkdirSync(tasksDir, { recursive: true }); + fs.writeFileSync( + path.join(teamsDir, "release-squad.json"), + JSON.stringify({ + teamName: "release-squad", + members: [ + { + id: "agent-db-reviewer", + displayName: "DB Reviewer", + color: "blue", + type: "code-reviewer", + }, + ], + }), + ); + fs.writeFileSync( + path.join(tasksDir, "task-team-opaque.json"), + JSON.stringify({ + taskId: "task-team-opaque", + teammateName: "DB Reviewer", + status: "running", + }), + ); + + harness.emit({ + type: "task.started", + eventId: asEventId("evt-team-artifact-name"), + provider: "claudeCode", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-team-artifact"), + payload: { + taskId: "task-team-opaque", + toolUseId: "tool-task-opaque", + teamName: "release-squad", + agentId: "agent-db-reviewer", + agentName: "agent-db-reviewer", + }, + }); + + const thread = await waitForThread(harness.engine, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => + activity.kind === "teammate.started" && + (activity.payload as Record | null)?.taskId === "task-team-opaque", + ), + ); + const started = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => + activity.kind === "teammate.started" && + (activity.payload as Record | null)?.taskId === "task-team-opaque", + ); + + expect(started).toMatchObject({ + summary: "DB Reviewer started", + payload: expect.objectContaining({ + agentId: "agent-db-reviewer", + teammateName: "DB Reviewer", + agentName: "DB Reviewer", + agentColor: "blue", + agentType: "code-reviewer", + teamName: "release-squad", + }), + }); + }); + + it("hydrates real Claude team config/task artifacts and reuses one run for sparse subagent launches", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + const teamDir = path.join(harness.workspaceRoot, ".claude", "teams", "test-team"); + const tasksDir = path.join(harness.workspaceRoot, ".claude", "tasks", "test-team"); + fs.mkdirSync(teamDir, { recursive: true }); + fs.mkdirSync(tasksDir, { recursive: true }); + fs.writeFileSync( + path.join(teamDir, "config.json"), + JSON.stringify({ + name: "test-team", + leadAgentId: "team-lead@test-team", + members: [ + { + agentId: "team-lead@test-team", + name: "team-lead", + agentType: "general-purpose", + }, + { + agentId: "researcher@test-team", + name: "researcher", + agentType: "general-purpose", + color: "blue", + }, + { + agentId: "tester@test-team", + name: "tester", + agentType: "general-purpose", + color: "green", + }, + ], + }), + ); + fs.writeFileSync( + path.join(tasksDir, "1.json"), + JSON.stringify({ + id: "1", + subject: "researcher", + status: "in_progress", + }), + ); + fs.writeFileSync( + path.join(tasksDir, "2.json"), + JSON.stringify({ + id: "2", + subject: "tester", + status: "in_progress", + }), + ); + + harness.emit({ + type: "item.started", + eventId: asEventId("evt-team-sparse-tool"), + provider: "claudeCode", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-team-sparse"), + itemId: asItemId("tool-task-1"), + payload: { + itemType: "collab_agent_tool_call", + title: "Subagent task", + detail: "Agent: {}", + }, + }); + + harness.emit({ + type: "task.started", + eventId: asEventId("evt-team-sparse-task"), + provider: "claudeCode", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-team-sparse"), + payload: { + taskId: "1", + toolUseId: "tool-task-1", + teamName: "test-team", + agentId: "researcher@test-team", + }, + }); + + const thread = await waitForThread(harness.engine, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => + activity.kind === "team.run.updated" && + (activity.payload as Record | null)?.teamName === "test-team" && + Array.isArray((activity.payload as Record | null)?.members), + ), + ); + + const runStartedActivities = thread.activities.filter( + (activity: ProviderRuntimeTestActivity) => activity.kind === "team.run.started", + ); + const runUpdated = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => + activity.kind === "team.run.updated" && + (activity.payload as Record | null)?.teamName === "test-team" && + Array.isArray((activity.payload as Record | null)?.members), + ); + const teammateStarted = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => + activity.kind === "teammate.started" && + (activity.payload as Record | null)?.taskId === "1", + ); + const sparseTool = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-team-sparse-tool", + ); + + expect(runStartedActivities).toHaveLength(1); + expect(runUpdated).toMatchObject({ + payload: expect.objectContaining({ + teamName: "test-team", + members: [ + expect.objectContaining({ + agentId: "researcher@test-team", + teammateName: "researcher", + agentColor: "blue", + }), + expect.objectContaining({ + agentId: "tester@test-team", + teammateName: "tester", + agentColor: "green", + }), + ], + tasks: [ + expect.objectContaining({ + taskId: "1", + teammateName: "researcher", + status: "running", + }), + expect.objectContaining({ + taskId: "2", + teammateName: "tester", + status: "running", + }), + ], + }), + }); + expect( + (runUpdated?.payload as Record | undefined)?.members as + | unknown[] + | undefined, + ).toHaveLength(2); + expect(teammateStarted).toMatchObject({ + summary: "researcher started", + payload: expect.objectContaining({ + runId: (runStartedActivities[0]?.payload as Record)?.runId, + teamName: "test-team", + teammateName: "researcher", + agentColor: "blue", + }), + }); + expect(sparseTool).toMatchObject({ + payload: expect.objectContaining({ + runId: (runStartedActivities[0]?.payload as Record)?.runId, + }), + }); + }); + it("uses assistant item completion detail when no assistant deltas were streamed", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -1354,6 +1770,7 @@ describe("ProviderRuntimeIngestion", () => { payload: { taskId: "turn-task-1", description: "Comparing the desktop rollout chunks to the app-server stream.", + summary: "Code reviewer is validating the desktop rollout chunks.", }, }); @@ -1416,8 +1833,9 @@ describe("ProviderRuntimeIngestion", () => { expect(started?.kind).toBe("task.started"); expect(started?.summary).toBe("Plan task started"); expect(progress?.kind).toBe("task.progress"); - expect(progressPayload?.detail).toBe( - "Comparing the desktop rollout chunks to the app-server stream.", + expect(progressPayload?.detail).toBe("Code reviewer is validating the desktop rollout chunks."); + expect(progressPayload?.summary).toBe( + "Code reviewer is validating the desktop rollout chunks.", ); expect(completed?.kind).toBe("task.completed"); expect(completedPayload?.detail).toBe("\n# Plan title\n"); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 417e93c8d..b99cf0b7e 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1,7 +1,12 @@ +import fs from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; + import { ApprovalRequestId, type AssistantDeliveryMode, CommandId, + EventId, MessageId, type OrchestrationEvent, CheckpointRef, @@ -11,7 +16,7 @@ import { type OrchestrationThreadActivity, type ProviderRuntimeEvent, } from "@t3tools/contracts"; -import { Cache, Cause, Duration, Effect, Layer, Option, Ref, Stream } from "effect"; +import { Cache, Cause, Data, Duration, Effect, Layer, Option, Ref, Stream } from "effect"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; @@ -34,6 +39,8 @@ const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000; const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL = Duration.minutes(120); const BUFFERED_PROPOSED_PLAN_BY_ID_CACHE_CAPACITY = 10_000; const BUFFERED_PROPOSED_PLAN_BY_ID_TTL = Duration.minutes(120); +const BUFFERED_REASONING_BY_ID_CACHE_CAPACITY = 10_000; +const BUFFERED_REASONING_BY_ID_TTL = Duration.minutes(120); const MAX_BUFFERED_ASSISTANT_CHARS = 24_000; const STRICT_PROVIDER_LIFECYCLE_GUARD = process.env.T3CODE_STRICT_PROVIDER_LIFECYCLE_GUARD !== "0"; @@ -52,6 +59,11 @@ type RuntimeIngestionInput = event: TurnStartRequestedDomainEvent; }; +class ClaudeArtifactSnapshotError extends Data.TaggedError("ClaudeArtifactSnapshotError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + function toTurnId(value: TurnId | string | undefined): TurnId | undefined { return value === undefined ? undefined : TurnId.makeUnsafe(String(value)); } @@ -94,10 +106,27 @@ function proposedPlanIdFromEvent(event: ProviderRuntimeEvent, threadId: ThreadId return `plan:${threadId}:event:${event.eventId}`; } +function reasoningActivityIdFromEvent(event: ProviderRuntimeEvent, threadId: ThreadId): EventId { + const turnId = toTurnId(event.turnId); + if (turnId) { + return EventId.makeUnsafe(`thinking:${threadId}:turn:${turnId}`); + } + if (event.itemId) { + return EventId.makeUnsafe(`thinking:${threadId}:item:${event.itemId}`); + } + return EventId.makeUnsafe(`thinking:${threadId}:event:${event.eventId}`); +} + function asString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } +function asRecord(value: unknown): Record | undefined { + return value !== null && typeof value === "object" + ? (value as Record) + : undefined; +} + function runtimePayloadRecord(event: ProviderRuntimeEvent): Record | undefined { const payload = (event as { payload?: unknown }).payload; if (!payload || typeof payload !== "object") { @@ -137,6 +166,82 @@ function runtimeErrorMessageFromEvent(event: ProviderRuntimeEvent): string | und return payloadMessage; } +function teamMetadataPayload(payload: Record): Record { + return { + ...(asString(payload.agentId) ? { agentId: asString(payload.agentId) } : {}), + ...(asString(payload.agentName) ? { agentName: asString(payload.agentName) } : {}), + ...(asString(payload.agentColor) ? { agentColor: asString(payload.agentColor) } : {}), + ...(asString(payload.agentType) ? { agentType: asString(payload.agentType) } : {}), + ...(asString(payload.teamName) ? { teamName: asString(payload.teamName) } : {}), + ...(asString(payload.teammateName) ? { teammateName: asString(payload.teammateName) } : {}), + ...(asString(payload.parentSessionId) + ? { parentSessionId: asString(payload.parentSessionId) } + : {}), + ...(asString(payload.teammateMode) ? { teammateMode: asString(payload.teammateMode) } : {}), + ...(asString(payload.toolUseId) ? { toolUseId: asString(payload.toolUseId) } : {}), + ...(asString(payload.runId) ? { runId: asString(payload.runId) } : {}), + ...(asString(payload.teamKey) ? { teamKey: asString(payload.teamKey) } : {}), + ...(asString(payload.statusSource) ? { statusSource: asString(payload.statusSource) } : {}), + ...(typeof payload.planModeRequired === "boolean" + ? { planModeRequired: payload.planModeRequired } + : {}), + ...(typeof payload.awaitingLeaderApproval === "boolean" + ? { awaitingLeaderApproval: payload.awaitingLeaderApproval } + : {}), + }; +} + +function extractTeammateNameFromDetail(detail: unknown): string | undefined { + if (typeof detail !== "string") return undefined; + const match = /^([^:]+):\s/.exec(detail); + return match?.[1]?.trim(); +} + +function hasTeamMetadata(payload: Record): boolean { + return ( + asString(payload.runId) !== undefined || + asString(payload.teamName) !== undefined || + asString(payload.teammateName) !== undefined || + asString(payload.agentName) !== undefined || + payload.taskType === "in_process_teammate" + ); +} + +function teammateActivityLabel(payload: Record): string { + return ( + asString(payload.teammateName) ?? + asString(payload.agentName) ?? + (payload.taskType === "in_process_teammate" + ? extractTeammateNameFromDetail(payload.detail ?? payload.description) + : undefined) ?? + asString(payload.agentType) ?? + asString(payload.teamName) ?? + "Teammate" + ); +} + +function teammateActivitySummary( + kind: string, + payload: Record, + fallbackSummary: string, +): string { + const label = teammateActivityLabel(payload); + switch (kind) { + case "teammate.started": + return `${label} started`; + case "teammate.progress": + return `${label} update`; + case "teammate.completed": + return `${label} completed`; + case "teammate.failed": + return `${label} failed`; + case "teammate.stopped": + return `${label} stopped`; + default: + return fallbackSummary; + } +} + function orchestrationSessionStatusFromRuntimeState( state: "starting" | "running" | "waiting" | "ready" | "interrupted" | "stopped" | "error", ): "starting" | "running" | "ready" | "interrupted" | "stopped" | "error" { @@ -174,6 +279,334 @@ function requestKindFromCanonicalRequestType( } } +type TeamStatusSource = "runtime" | "claude-files"; + +type TeamArtifactMember = { + agentId?: string; + teammateName?: string; + agentName?: string; + agentColor?: string; + agentType?: string; +}; + +type TeamArtifactTask = { + taskId?: string; + teammateName?: string; + summary?: string; + status?: string; + updatedAt?: string; +}; + +type TeamArtifactSnapshot = { + teamName?: string; + members: ReadonlyArray; + tasks: ReadonlyArray; + endedAt?: string; + endedReason?: string; +}; + +type TeamRunInfo = { + readonly runId: string; + readonly teamKey: string; + readonly startedAt: string; + readonly isNew: boolean; +}; + +function isUuid(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); +} + +function normalizeTeamKey(value: string): string { + const normalized = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/-+/g, "-"); + return normalized.replace(/^-+|-+$/g, "") || "agent-team"; +} + +function activeTeamRuns(activities: ReadonlyArray): ReadonlyArray<{ + readonly runId: string; + readonly teamKey: string; + readonly turnId: TurnId | null; + readonly startedAt: string; +}> { + const endedRunIds = new Set( + activities + .filter((activity) => activity.kind === "team.run.ended") + .map((activity) => asString(asRecord(activity.payload)?.runId)) + .filter((value): value is string => value !== undefined), + ); + + return activities + .filter((activity) => activity.kind === "team.run.started") + .map((activity) => { + const payload = asRecord(activity.payload); + const runId = asString(payload?.runId); + const teamKey = asString(payload?.teamKey); + if (!runId || !teamKey || endedRunIds.has(runId)) { + return null; + } + return { + runId, + teamKey, + turnId: activity.turnId, + startedAt: activity.createdAt, + }; + }) + .filter( + ( + value, + ): value is { + readonly runId: string; + readonly teamKey: string; + readonly turnId: TurnId | null; + readonly startedAt: string; + } => value !== null, + ); +} + +function teamKeyFromPayload( + activities: ReadonlyArray, + payload: Record, + turnId: TurnId | null, +): string | undefined { + const explicitTeamKey = asString(payload.teamKey); + if (explicitTeamKey) { + return explicitTeamKey; + } + + const runId = asString(payload.runId); + if (runId) { + const matchingRun = activities.find( + (activity) => + activity.kind === "team.run.started" && + asString(asRecord(activity.payload)?.runId) === runId, + ); + const matchingRunTeamKey = asString(asRecord(matchingRun?.payload)?.teamKey); + if (matchingRunTeamKey) { + return matchingRunTeamKey; + } + } + + const openRuns = activeTeamRuns(activities); + const sameTurnRuns = + turnId === null ? [] : openRuns.filter((candidate) => sameId(candidate.turnId, turnId)); + if (sameTurnRuns.length === 1) { + return sameTurnRuns[0]!.teamKey; + } + + const explicitIdentity = asString(payload.teamName) ?? asString(payload.parentSessionId); + if (explicitIdentity) { + return explicitIdentity; + } + + if (openRuns.length === 1) { + return openRuns[0]!.teamKey; + } + + return turnId ? `turn:${turnId}` : undefined; +} + +function teamMemberKey(payload: Record): string | undefined { + return ( + asString(payload.agentId) ?? + asString(payload.toolUseId) ?? + asString(payload.taskId) ?? + asString(payload.teammateName) ?? + asString(payload.agentName) + ); +} + +function teammateStatusFromKind( + kind: string, +): "running" | "idle" | "awaitingApproval" | "completed" | "failed" | "stopped" | undefined { + switch (kind) { + case "teammate.started": + case "teammate.progress": + return "running"; + case "teammate.idle": + return "idle"; + case "teammate.awaiting-approval": + return "awaitingApproval"; + case "teammate.completed": + return "completed"; + case "teammate.failed": + return "failed"; + case "teammate.stopped": + return "stopped"; + default: + return undefined; + } +} + +function isTerminalTeammateStatus( + status: ReturnType, +): status is "completed" | "failed" | "stopped" { + return status === "completed" || status === "failed" || status === "stopped"; +} + +function currentTeamRunInfo( + activities: ReadonlyArray, + payload: Record, + turnId: TurnId | null, + createdAt: string, +): TeamRunInfo | null { + const rawTeamKey = teamKeyFromPayload(activities, payload, turnId); + if (!rawTeamKey) { + return null; + } + const teamKey = normalizeTeamKey(rawTeamKey); + const started = [...activities] + .filter((activity) => activity.kind === "team.run.started") + .filter((activity) => { + const activityPayload = asRecord(activity.payload); + return asString(activityPayload?.teamKey) === teamKey; + }) + .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)); + const endedRunIds = new Set(activeTeamRuns(activities).map((candidate) => candidate.runId)); + const activeRun = started + .map((activity) => { + const activityPayload = asRecord(activity.payload); + const runId = asString(activityPayload?.runId); + return runId ? { runId, startedAt: activity.createdAt } : null; + }) + .filter((value): value is { runId: string; startedAt: string } => value !== null) + .findLast((candidate) => endedRunIds.has(candidate.runId)); + if (activeRun) { + return { + runId: activeRun.runId, + teamKey, + startedAt: activeRun.startedAt, + isNew: false, + }; + } + const nextOrdinal = started.length + 1; + return { + runId: `team-run:${teamKey}:${nextOrdinal}`, + teamKey, + startedAt: createdAt, + isNew: true, + }; +} + +function runHasEnded( + activities: ReadonlyArray, + runId: string, +): boolean { + return activities.some( + (activity) => + activity.kind === "team.run.ended" && asString(asRecord(activity.payload)?.runId) === runId, + ); +} + +function teamRunShouldEnd( + activities: ReadonlyArray, + currentActivity: OrchestrationThreadActivity, + runId: string, +): boolean { + const candidateActivities = [...activities, currentActivity].toSorted( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + ); + const statuses = new Map>(); + for (const activity of candidateActivities) { + if (!activity.kind.startsWith("teammate.")) { + continue; + } + const payload = asRecord(activity.payload); + if (asString(payload?.runId) !== runId) { + continue; + } + const memberKey = teamMemberKey(payload ?? {}); + if (!memberKey) { + continue; + } + statuses.set(memberKey, teammateStatusFromKind(activity.kind)); + } + if (statuses.size === 0) { + return false; + } + return [...statuses.values()].every((status) => isTerminalTeammateStatus(status)); +} + +function teamPayloadFromArtifacts( + payload: Record, + artifactSnapshot: TeamArtifactSnapshot | undefined, +): Record { + if (!artifactSnapshot) { + return payload; + } + const runtimeAgentId = asString(payload.agentId); + const runtimeTeammateName = asString(payload.teammateName); + const runtimeAgentName = asString(payload.agentName); + const runtimeTaskId = asString(payload.taskId); + const matchingTask = artifactSnapshot.tasks.find((task) => { + if (runtimeTaskId && task.taskId === runtimeTaskId) { + return true; + } + const candidateName = task.teammateName; + return Boolean( + candidateName && + (candidateName === runtimeTeammateName || + candidateName === runtimeAgentName || + candidateName === runtimeAgentId), + ); + }); + const matchingMember = artifactSnapshot.members.find((member) => { + const taskTeammateName = matchingTask?.teammateName; + return ( + (runtimeAgentId && + (member.agentId === runtimeAgentId || + member.agentName === runtimeAgentId || + member.teammateName === runtimeAgentId)) || + (runtimeTeammateName && + (member.teammateName === runtimeTeammateName || + member.agentName === runtimeTeammateName)) || + (runtimeAgentName && + (member.agentName === runtimeAgentName || member.teammateName === runtimeAgentName)) || + (taskTeammateName && + (member.teammateName === taskTeammateName || member.agentName === taskTeammateName)) + ); + }); + const artifactTeammateName = matchingTask?.teammateName ?? matchingMember?.teammateName; + const artifactAgentName = matchingMember?.agentName ?? artifactTeammateName; + const shouldPreferArtifactName = (value: string | undefined) => + value === undefined || + value === runtimeAgentId || + isUuid(value) || + /^agent[-_:]/i.test(value) || + /^subagent[-_:]/i.test(value); + return { + ...payload, + ...(asString(payload.teamName) + ? {} + : artifactSnapshot.teamName + ? { teamName: artifactSnapshot.teamName } + : {}), + ...(shouldPreferArtifactName(runtimeTeammateName) + ? artifactTeammateName + ? { teammateName: artifactTeammateName } + : {} + : {}), + ...(shouldPreferArtifactName(runtimeAgentName) + ? artifactAgentName + ? { agentName: artifactAgentName } + : {} + : {}), + ...(asString(payload.agentColor) + ? {} + : matchingMember?.agentColor + ? { agentColor: matchingMember.agentColor } + : {}), + ...(asString(payload.agentType) + ? {} + : matchingMember?.agentType + ? { agentType: matchingMember.agentType } + : {}), + }; +} + function runtimeEventToActivities( event: ProviderRuntimeEvent, ): ReadonlyArray { @@ -335,14 +768,23 @@ function runtimeEventToActivities( } case "task.started": { + const rawPayload = event.payload as Record; + const metadata = teamMetadataPayload(rawPayload); + const teammateLabel = teammateActivityLabel(rawPayload); + const isTeamTask = hasTeamMetadata(rawPayload); + const inferredName = + isTeamTask && !metadata.teammateName && !metadata.agentName + ? extractTeammateNameFromDetail(rawPayload.detail ?? rawPayload.description) + : undefined; return [ { id: event.eventId, createdAt: event.createdAt, tone: "info", - kind: "task.started", - summary: - event.payload.taskType === "plan" + kind: isTeamTask ? "teammate.started" : "task.started", + summary: isTeamTask + ? `${teammateLabel} started` + : event.payload.taskType === "plan" ? "Plan task started" : event.payload.taskType ? `${event.payload.taskType} task started` @@ -353,6 +795,8 @@ function runtimeEventToActivities( ...(event.payload.description ? { detail: truncateDetail(event.payload.description) } : {}), + ...(inferredName ? { teammateName: inferredName } : {}), + ...metadata, }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -361,18 +805,42 @@ function runtimeEventToActivities( } case "task.progress": { + const rawProgressPayload = event.payload as Record; + const metadata = teamMetadataPayload(rawProgressPayload); + const teammateLabel = teammateActivityLabel(rawProgressPayload); + const isTeamTask = hasTeamMetadata(rawProgressPayload); + const awaitingLeaderApproval = event.payload.awaitingLeaderApproval === true; + const inferredProgressName = + isTeamTask && !metadata.teammateName && !metadata.agentName + ? extractTeammateNameFromDetail( + rawProgressPayload.detail ?? + rawProgressPayload.summary ?? + rawProgressPayload.description, + ) + : undefined; return [ { id: event.eventId, createdAt: event.createdAt, tone: "info", - kind: "task.progress", - summary: "Reasoning update", + kind: awaitingLeaderApproval + ? "teammate.awaiting-approval" + : isTeamTask + ? "teammate.progress" + : "task.progress", + summary: awaitingLeaderApproval + ? "Leader approval requested" + : isTeamTask + ? `${teammateLabel} update` + : "Reasoning update", payload: { taskId: event.payload.taskId, - detail: truncateDetail(event.payload.description), + detail: truncateDetail(event.payload.summary ?? event.payload.description), + ...(event.payload.summary ? { summary: truncateDetail(event.payload.summary) } : {}), ...(event.payload.lastToolName ? { lastToolName: event.payload.lastToolName } : {}), ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), + ...(inferredProgressName ? { teammateName: inferredProgressName } : {}), + ...metadata, }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -381,14 +849,37 @@ function runtimeEventToActivities( } case "task.completed": { + const rawCompletedPayload = event.payload as Record; + const metadata = teamMetadataPayload(rawCompletedPayload); + const teammateLabel = teammateActivityLabel(rawCompletedPayload); + const isTeamTask = hasTeamMetadata(rawCompletedPayload); + const inferredCompletedName = + isTeamTask && !metadata.teammateName && !metadata.agentName + ? extractTeammateNameFromDetail( + rawCompletedPayload.detail ?? + rawCompletedPayload.summary ?? + rawCompletedPayload.description, + ) + : undefined; return [ { id: event.eventId, createdAt: event.createdAt, tone: event.payload.status === "failed" ? "error" : "info", - kind: "task.completed", - summary: - event.payload.status === "failed" + kind: isTeamTask + ? event.payload.status === "failed" + ? "teammate.failed" + : event.payload.status === "stopped" + ? "teammate.stopped" + : "teammate.completed" + : "task.completed", + summary: isTeamTask + ? event.payload.status === "failed" + ? `${teammateLabel} failed` + : event.payload.status === "stopped" + ? `${teammateLabel} stopped` + : `${teammateLabel} completed` + : event.payload.status === "failed" ? "Task failed" : event.payload.status === "stopped" ? "Task stopped" @@ -397,7 +888,52 @@ function runtimeEventToActivities( taskId: event.payload.taskId, status: event.payload.status, ...(event.payload.summary ? { detail: truncateDetail(event.payload.summary) } : {}), + ...(inferredCompletedName ? { teammateName: inferredCompletedName } : {}), ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), + ...metadata, + }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ]; + } + + case "hook.started": { + const metadata = teamMetadataPayload(event.payload as Record); + const hookEvent = event.payload.hookEvent; + const isTeamHook = hasTeamMetadata(event.payload as Record); + const hookEventKind = + isTeamHook && hookEvent === "SubagentStart" + ? "teammate.started" + : isTeamHook && hookEvent === "TeammateIdle" + ? "teammate.idle" + : isTeamHook && hookEvent === "TaskCompleted" + ? "teammate.completed" + : isTeamHook && hookEvent === "SubagentStop" + ? "teammate.stopped" + : "hook.started"; + const hookSummary = + isTeamHook && hookEvent === "SubagentStart" + ? "Teammate started" + : isTeamHook && hookEvent === "TeammateIdle" + ? "Teammate idle" + : isTeamHook && hookEvent === "TaskCompleted" + ? "Teammate completed" + : isTeamHook && hookEvent === "SubagentStop" + ? "Teammate stopped" + : `Hook started: ${event.payload.hookEvent}`; + return [ + { + id: event.eventId, + createdAt: event.createdAt, + tone: "info", + kind: hookEventKind, + summary: hookSummary, + payload: { + hookId: event.payload.hookId, + hookName: event.payload.hookName, + hookEvent, + ...metadata, }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -409,6 +945,7 @@ function runtimeEventToActivities( if (!isToolLifecycleItemType(event.payload.itemType)) { return []; } + const metadata = teamMetadataPayload(event.payload as Record); return [ { id: event.eventId, @@ -421,6 +958,7 @@ function runtimeEventToActivities( ...(event.payload.status ? { status: event.payload.status } : {}), ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), + ...metadata, }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -432,6 +970,7 @@ function runtimeEventToActivities( if (!isToolLifecycleItemType(event.payload.itemType)) { return []; } + const metadata = teamMetadataPayload(event.payload as Record); return [ { id: event.eventId, @@ -442,6 +981,7 @@ function runtimeEventToActivities( payload: { itemType: event.payload.itemType, ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), + ...metadata, }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -453,6 +993,7 @@ function runtimeEventToActivities( if (!isToolLifecycleItemType(event.payload.itemType)) { return []; } + const metadata = teamMetadataPayload(event.payload as Record); return [ { id: event.eventId, @@ -463,6 +1004,7 @@ function runtimeEventToActivities( payload: { itemType: event.payload.itemType, ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), + ...metadata, }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -503,6 +1045,12 @@ const make = Effect.gen(function* () { lookup: () => Effect.succeed({ text: "", createdAt: "" }), }); + const bufferedReasoningById = yield* Cache.make({ + capacity: BUFFERED_REASONING_BY_ID_CACHE_CAPACITY, + timeToLive: BUFFERED_REASONING_BY_ID_TTL, + lookup: () => Effect.succeed({ text: "", createdAt: "" }), + }); + const isGitRepoForThread = Effect.fnUntraced(function* (threadId: ThreadId) { const readModel = yield* orchestrationEngine.getReadModel(); const thread = readModel.threads.find((entry) => entry.id === threadId); @@ -519,6 +1067,490 @@ const make = Effect.gen(function* () { return isGitRepository(workspaceCwd); }); + const readClaudeTeamArtifactSnapshot = ( + workspaceCwd: string | null, + payload: Record, + ): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const roots = [workspaceCwd, homedir()] + .filter((value): value is string => typeof value === "string" && value.length > 0) + .flatMap((root) => [ + path.join(root, ".claude", "teams"), + path.join(root, ".claude", "tasks"), + ]); + const seen = new Set(); + const uniqueRoots = roots.filter((root) => { + if (seen.has(root)) { + return false; + } + seen.add(root); + return true; + }); + const teamName = asString(payload.teamName); + const teammateName = asString(payload.teammateName) ?? asString(payload.agentName); + const desiredTaskId = asString(payload.taskId); + + const jsonFiles: string[] = []; + const visit = async (root: string, depth: number) => { + if (depth > 3 || jsonFiles.length >= 64) { + return; + } + let entries: Array<{ + readonly name: string; + readonly isDirectory: () => boolean; + readonly isFile: () => boolean; + }>; + try { + entries = (await fs.readdir(root, { + withFileTypes: true, + })) as unknown as typeof entries; + } catch { + return; + } + for (const entry of entries) { + if (jsonFiles.length >= 64) { + break; + } + const absolutePath = path.join(root, entry.name); + if (entry.isDirectory()) { + await visit(absolutePath, depth + 1); + continue; + } + if (entry.isFile() && entry.name.endsWith(".json")) { + jsonFiles.push(absolutePath); + } + } + }; + await Promise.all(uniqueRoots.map((root) => visit(root, 0))); + + const extractMembers = (record: Record): TeamArtifactMember[] => { + const leadAgentId = + asString(record.leadAgentId) ?? + asString(record.lead_agent_id) ?? + asString(asRecord(record.team)?.leadAgentId) ?? + asString(asRecord(record.team)?.lead_agent_id); + const candidates = [ + record.members, + record.teammates, + record.agents, + asRecord(record.team)?.members, + ]; + for (const candidate of candidates) { + if (!Array.isArray(candidate)) { + continue; + } + return candidate + .map((entry) => asRecord(entry)) + .filter((entry): entry is Record => entry !== undefined) + .map((entry) => { + const teammateRecord = asRecord(entry.teammate); + const agentRecord = asRecord(entry.agent); + const member: TeamArtifactMember = {}; + const agentId = + asString(entry.agentId) ?? + asString(entry.agent_id) ?? + asString(entry.teammateId) ?? + asString(entry.teammate_id) ?? + asString(entry.id) ?? + asString(teammateRecord?.id) ?? + asString(agentRecord?.id); + const teammateName = + asString(entry.teammateName) ?? + asString(entry.teammate_name) ?? + asString(entry.displayName) ?? + asString(entry.display_name) ?? + asString(entry.label) ?? + asString(entry.name) ?? + asString(teammateRecord?.displayName) ?? + asString(teammateRecord?.display_name) ?? + asString(teammateRecord?.label) ?? + asString(teammateRecord?.name); + const agentName = + asString(entry.agentName) ?? + asString(entry.agent_name) ?? + asString(agentRecord?.displayName) ?? + asString(agentRecord?.display_name) ?? + asString(agentRecord?.label) ?? + asString(agentRecord?.name); + const agentColor = asString(entry.agentColor) ?? asString(entry.color); + const agentType = asString(entry.agentType) ?? asString(entry.type); + if (agentId) { + member.agentId = agentId; + } + if (teammateName) { + member.teammateName = teammateName; + } + if (agentName) { + member.agentName = agentName; + } + if (agentColor) { + member.agentColor = agentColor; + } + if (agentType) { + member.agentType = agentType; + } + return member; + }) + .filter( + (member) => + (member.agentId !== undefined || + member.teammateName !== undefined || + member.agentName !== undefined || + member.agentColor !== undefined || + member.agentType !== undefined) && + member.agentId !== leadAgentId && + member.teammateName !== "team-lead" && + member.agentName !== "team-lead", + ); + } + return []; + }; + + const normalizeArtifactTaskStatus = (value: unknown): string | undefined => { + const normalized = asString(value)?.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + if ( + normalized === "in_progress" || + normalized === "in-progress" || + normalized === "running" + ) { + return "running"; + } + if (normalized === "idle") { + return "idle"; + } + if ( + normalized === "pending_approval" || + normalized === "pending-approval" || + normalized === "awaiting_approval" || + normalized === "awaiting-approval" + ) { + return "awaitingApproval"; + } + if (normalized === "completed" || normalized === "done") { + return "completed"; + } + if (normalized === "failed" || normalized === "error") { + return "failed"; + } + if ( + normalized === "stopped" || + normalized === "shutdown" || + normalized === "shut_down" || + normalized === "shut-down" || + normalized === "terminated" + ) { + return "stopped"; + } + return normalized; + }; + + const extractTaskRecord = (record: Record): TeamArtifactTask[] => { + const looksLikeTeamConfig = + Array.isArray(record.members) || + Array.isArray(asRecord(record.team)?.members) || + asString(record.leadAgentId) !== undefined || + asString(record.lead_agent_id) !== undefined; + const directTaskId = + asString(record.taskId) ?? asString(record.task_id) ?? asString(record.id); + const directStatus = normalizeArtifactTaskStatus(record.status); + const directSummary = + asString(record.summary) ?? asString(record.description) ?? asString(record.title); + const directTeammate = + asString(record.teammateName) ?? + asString(record.subject) ?? + asString(record.assignee) ?? + asString(record.agentName) ?? + asString(record.name); + const directUpdatedAt = + asString(record.updatedAt) ?? + asString(record.completedAt) ?? + asString(record.endedAt) ?? + asString(record.startedAt); + if ( + !looksLikeTeamConfig && + (directTaskId || directStatus || directSummary || directTeammate) + ) { + return [ + { + ...(directTaskId ? { taskId: directTaskId } : {}), + ...(directTeammate ? { teammateName: directTeammate } : {}), + ...(directSummary ? { summary: directSummary } : {}), + ...(directStatus ? { status: directStatus } : {}), + ...(directUpdatedAt ? { updatedAt: directUpdatedAt } : {}), + }, + ]; + } + const nestedTaskLists = [record.tasks, record.items, record.history]; + for (const candidate of nestedTaskLists) { + if (!Array.isArray(candidate)) { + continue; + } + return candidate + .map((entry) => asRecord(entry)) + .filter((entry): entry is Record => entry !== undefined) + .flatMap((entry) => extractTaskRecord(entry)); + } + return []; + }; + + type ParsedArtifact = { + readonly teamName?: string; + readonly members: TeamArtifactMember[]; + readonly tasks: TeamArtifactTask[]; + }; + + const parsedArtifacts: ParsedArtifact[] = []; + for (const filePath of jsonFiles) { + let parsed: unknown; + try { + parsed = JSON.parse(await fs.readFile(filePath, "utf8")); + } catch { + continue; + } + const record = asRecord(parsed); + if (!record) { + continue; + } + parsedArtifacts.push({ + teamName: + asString(record.teamName) ?? + asString(record.team_name) ?? + asString(record.name) ?? + path.basename(path.dirname(filePath)), + members: extractMembers(record), + tasks: extractTaskRecord(record), + }); + } + + const matchingArtifacts = parsedArtifacts.filter((artifact) => { + if (teamName && artifact.teamName === teamName) { + return true; + } + if (teammateName) { + return ( + artifact.members.some( + (member) => + member.teammateName === teammateName || member.agentName === teammateName, + ) || artifact.tasks.some((task) => task.teammateName === teammateName) + ); + } + if (desiredTaskId) { + return artifact.tasks.some((task) => task.taskId === desiredTaskId); + } + return false; + }); + + if (matchingArtifacts.length === 0) { + return undefined; + } + + const selectedTeamName = + teamName ?? + matchingArtifacts.find((artifact) => artifact.teamName !== undefined)?.teamName ?? + matchingArtifacts[0]?.teamName; + const relatedArtifacts = selectedTeamName + ? matchingArtifacts.filter((artifact) => artifact.teamName === selectedTeamName) + : matchingArtifacts; + if (relatedArtifacts.length === 0) { + return undefined; + } + + const membersByKey = new Map(); + for (const artifact of relatedArtifacts) { + for (const member of artifact.members) { + const key = + member.agentId ?? + member.teammateName ?? + member.agentName ?? + `member:${membersByKey.size + 1}`; + const existing = membersByKey.get(key); + membersByKey.set(key, { + ...existing, + ...member, + }); + } + } + + const tasksByKey = new Map(); + for (const artifact of relatedArtifacts) { + for (const task of artifact.tasks) { + const key = + task.taskId ?? task.teammateName ?? task.summary ?? `task:${tasksByKey.size + 1}`; + const existing = tasksByKey.get(key); + tasksByKey.set(key, { + ...existing, + ...task, + }); + } + } + + const members = [...membersByKey.values()]; + const tasks = [...tasksByKey.values()]; + const terminalStatuses = new Set(["completed", "failed", "stopped"]); + const allTasksTerminal = + tasks.length > 0 && tasks.every((task) => terminalStatuses.has(task.status ?? "")); + const endedAt = allTasksTerminal + ? tasks + .map((task) => task.updatedAt) + .filter((value): value is string => value !== undefined) + .toSorted((left, right) => right.localeCompare(left))[0] + : undefined; + return { + ...(selectedTeamName ? { teamName: selectedTeamName } : {}), + members, + tasks, + ...(endedAt ? { endedAt } : {}), + ...(allTasksTerminal ? { endedReason: "claude-file-status" } : {}), + }; + }, + catch: (cause) => + new ClaudeArtifactSnapshotError({ + message: "Failed to read Claude team artifacts.", + cause, + }), + }).pipe(Effect.catchTag("ClaudeArtifactSnapshotError", () => Effect.succeed(undefined))); + + const enrichTeamActivities = (input: { + readonly thread: { + readonly activities: ReadonlyArray; + }; + readonly workspaceCwd: string | null; + readonly baseActivities: ReadonlyArray; + }) => + Effect.forEach( + input.baseActivities, + (activity) => + Effect.gen(function* () { + const payload = asRecord(activity.payload); + const isSparseCollabAgentToolActivity = + (activity.kind === "tool.started" || + activity.kind === "tool.updated" || + activity.kind === "tool.completed") && + asString(payload?.itemType) === "collab_agent_tool_call"; + if (!payload || (!hasTeamMetadata(payload) && !isSparseCollabAgentToolActivity)) { + return [activity] as const; + } + + const artifactSnapshot = hasTeamMetadata(payload) + ? yield* readClaudeTeamArtifactSnapshot(input.workspaceCwd, payload) + : undefined; + const mergedPayload = teamPayloadFromArtifacts(payload, artifactSnapshot); + const runInfo = currentTeamRunInfo( + input.thread.activities, + mergedPayload, + activity.turnId, + activity.createdAt, + ); + if (!runInfo) { + return [ + { + ...activity, + payload: { + ...mergedPayload, + statusSource: "runtime" satisfies TeamStatusSource, + }, + }, + ] as const; + } + + const enrichedActivity: OrchestrationThreadActivity = { + ...activity, + summary: teammateActivitySummary(activity.kind, mergedPayload, activity.summary), + payload: { + ...mergedPayload, + runId: runInfo.runId, + teamKey: runInfo.teamKey, + statusSource: "runtime" satisfies TeamStatusSource, + }, + }; + const label = asString(mergedPayload.teamName) ?? teammateActivityLabel(mergedPayload); + const lifecycleActivities: OrchestrationThreadActivity[] = []; + + if (runInfo.isNew) { + lifecycleActivities.push({ + id: EventId.makeUnsafe(`${activity.id}:team-run-started`), + createdAt: activity.createdAt, + tone: "info", + kind: "team.run.started", + summary: `${label} team started`, + payload: { + runId: runInfo.runId, + teamKey: runInfo.teamKey, + startedAt: runInfo.startedAt, + statusSource: "runtime" satisfies TeamStatusSource, + ...teamMetadataPayload(mergedPayload), + }, + turnId: activity.turnId, + }); + } + + lifecycleActivities.push({ + id: EventId.makeUnsafe(`${activity.id}:team-run-updated`), + createdAt: activity.createdAt, + tone: "info", + kind: "team.run.updated", + summary: `${label} team updated`, + payload: { + runId: runInfo.runId, + teamKey: runInfo.teamKey, + startedAt: runInfo.startedAt, + statusSource: (artifactSnapshot + ? "claude-files" + : "runtime") satisfies TeamStatusSource, + ...teamMetadataPayload(mergedPayload), + ...(artifactSnapshot + ? { members: artifactSnapshot.members, tasks: artifactSnapshot.tasks } + : {}), + }, + turnId: activity.turnId, + }); + + if ( + !runHasEnded(input.thread.activities, runInfo.runId) && + (teamRunShouldEnd(input.thread.activities, enrichedActivity, runInfo.runId) || + artifactSnapshot?.endedAt) + ) { + lifecycleActivities.push({ + id: EventId.makeUnsafe(`${activity.id}:team-run-ended`), + createdAt: artifactSnapshot?.endedAt ?? activity.createdAt, + tone: "info", + kind: "team.run.ended", + summary: `${label} team shut down`, + payload: { + runId: runInfo.runId, + teamKey: runInfo.teamKey, + startedAt: runInfo.startedAt, + endedAt: artifactSnapshot?.endedAt ?? activity.createdAt, + reason: + artifactSnapshot?.endedReason ?? + (activity.kind === "teammate.stopped" + ? "teammate-stopped" + : "all-teammates-terminal"), + statusSource: (artifactSnapshot?.endedAt + ? "claude-files" + : "runtime") satisfies TeamStatusSource, + ...teamMetadataPayload(mergedPayload), + ...(artifactSnapshot + ? { members: artifactSnapshot.members, tasks: artifactSnapshot.tasks } + : {}), + }, + turnId: activity.turnId, + }); + } + + return [ + ...(runInfo.isNew ? [lifecycleActivities[0]!] : []), + enrichedActivity, + ...lifecycleActivities.slice(runInfo.isNew ? 1 : 0), + ] as const; + }), + { concurrency: 1 }, + ).pipe(Effect.map((groups) => groups.flat())); + const rememberAssistantMessageId = (threadId: ThreadId, turnId: TurnId, messageId: MessageId) => Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)).pipe( Effect.flatMap((existingIds) => @@ -620,6 +1652,56 @@ const make = Effect.gen(function* () { const clearBufferedProposedPlan = (planId: string) => Cache.invalidate(bufferedProposedPlanById, planId); + const appendBufferedReasoning = (activityId: EventId, delta: string, createdAt: string) => + Cache.getOption(bufferedReasoningById, activityId).pipe( + Effect.flatMap((existingEntry) => { + const existing = Option.getOrUndefined(existingEntry); + return Cache.set(bufferedReasoningById, activityId, { + text: `${existing?.text ?? ""}${delta}`, + createdAt: + existing?.createdAt && existing.createdAt.length > 0 ? existing.createdAt : createdAt, + }); + }), + ); + + const upsertReasoningActivity = (input: { + readonly event: ProviderRuntimeEvent; + readonly threadId: ThreadId; + readonly activityId: EventId; + readonly turnId?: TurnId; + readonly streamKind: "reasoning_text" | "reasoning_summary_text"; + readonly updatedAt: string; + }) => + Effect.gen(function* () { + const bufferedReasoning = yield* Cache.getOption( + bufferedReasoningById, + input.activityId, + ).pipe(Effect.map(Option.getOrUndefined)); + const detail = bufferedReasoning?.text.trim(); + if (!detail) { + return; + } + + yield* orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: providerCommandId(input.event, "reasoning-activity-upsert"), + threadId: input.threadId, + activity: { + id: input.activityId, + tone: "thinking", + kind: "reasoning.trace", + summary: "Thinking", + payload: { + detail, + streamKind: input.streamKind, + }, + turnId: input.turnId ?? null, + createdAt: bufferedReasoning?.createdAt ?? input.updatedAt, + }, + createdAt: input.updatedAt, + }); + }); + const clearAssistantMessageState = (messageId: MessageId) => clearBufferedAssistantText(messageId); @@ -741,8 +1823,10 @@ const make = Effect.gen(function* () { Effect.gen(function* () { const prefix = `${threadId}:`; const proposedPlanPrefix = `plan:${threadId}:`; + const reasoningPrefix = `thinking:${threadId}:`; const turnKeys = Array.from(yield* Cache.keys(turnMessageIdsByTurnKey)); const proposedPlanKeys = Array.from(yield* Cache.keys(bufferedProposedPlanById)); + const reasoningKeys = Array.from(yield* Cache.keys(bufferedReasoningById)); yield* Effect.forEach( turnKeys, (key) => @@ -770,6 +1854,14 @@ const make = Effect.gen(function* () { : Effect.void, { concurrency: 1 }, ).pipe(Effect.asVoid); + yield* Effect.forEach( + reasoningKeys, + (key) => + key.startsWith(reasoningPrefix) + ? Cache.invalidate(bufferedReasoningById, key) + : Effect.void, + { concurrency: 1 }, + ).pipe(Effect.asVoid); }); const processRuntimeEvent = (event: ProviderRuntimeEvent) => @@ -777,6 +1869,10 @@ const make = Effect.gen(function* () { const readModel = yield* orchestrationEngine.getReadModel(); const thread = readModel.threads.find((entry) => entry.id === event.threadId); if (!thread) return; + const workspaceCwd = resolveThreadWorkspaceCwd({ + thread, + projects: readModel.projects, + }); const now = event.createdAt; const eventTurnId = toTurnId(event.turnId); @@ -920,6 +2016,26 @@ const make = Effect.gen(function* () { yield* appendBufferedProposedPlan(planId, proposedPlanDelta, now); } + if ( + event.type === "content.delta" && + (event.payload.streamKind === "reasoning_text" || + event.payload.streamKind === "reasoning_summary_text") && + event.payload.delta.length > 0 + ) { + const activityId = reasoningActivityIdFromEvent(event, thread.id); + const reasoningTurnId = toTurnId(event.turnId); + const reasoningStreamKind = event.payload.streamKind; + yield* appendBufferedReasoning(activityId, event.payload.delta, now); + yield* upsertReasoningActivity({ + event, + threadId: thread.id, + activityId, + ...(reasoningTurnId ? { turnId: reasoningTurnId } : {}), + streamKind: reasoningStreamKind, + updatedAt: now, + }); + } + const assistantCompletion = event.type === "item.completed" && event.payload.itemType === "assistant_message" ? { @@ -1084,7 +2200,11 @@ const make = Effect.gen(function* () { } } - const activities = runtimeEventToActivities(event); + const activities = yield* enrichTeamActivities({ + thread, + workspaceCwd: workspaceCwd ?? null, + baseActivities: runtimeEventToActivities(event), + }); yield* Effect.forEach(activities, (activity) => orchestrationEngine.dispatch({ type: "thread.activity.append", diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts new file mode 100644 index 000000000..17c6c2ee8 --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts @@ -0,0 +1,1254 @@ +import type { + Options as ClaudeQueryOptions, + PermissionMode, + PermissionResult, + SDKMessage, + SDKUserMessage, +} from "@anthropic-ai/claude-agent-sdk"; +import { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import { Effect, Fiber, Random, Stream } from "effect"; + +import { ProviderAdapterValidationError } from "../Errors.ts"; +import { ClaudeCodeAdapter } from "../Services/ClaudeCodeAdapter.ts"; +import { + makeClaudeCodeAdapterLive, + type ClaudeCodeAdapterLiveOptions, +} from "./ClaudeCodeAdapter.ts"; + +class FakeClaudeQuery implements AsyncIterable { + private readonly queue: Array = []; + private readonly resolvers: Array<(value: IteratorResult) => void> = []; + private done = false; + + public readonly interruptCalls: Array = []; + public readonly setModelCalls: Array = []; + public readonly setPermissionModeCalls: Array = []; + public readonly setMaxThinkingTokensCalls: Array = []; + public closeCalls = 0; + + emit(message: SDKMessage): void { + if (this.done) { + return; + } + const resolver = this.resolvers.shift(); + if (resolver) { + resolver({ done: false, value: message }); + return; + } + this.queue.push(message); + } + + finish(): void { + if (this.done) { + return; + } + this.done = true; + for (const resolver of this.resolvers.splice(0)) { + resolver({ done: true, value: undefined }); + } + } + + readonly interrupt = async (): Promise => { + this.interruptCalls.push(undefined); + }; + + readonly setModel = async (model?: string): Promise => { + this.setModelCalls.push(model); + }; + + readonly setPermissionMode = async (mode: PermissionMode): Promise => { + this.setPermissionModeCalls.push(mode); + }; + + readonly setMaxThinkingTokens = async (maxThinkingTokens: number | null): Promise => { + this.setMaxThinkingTokensCalls.push(maxThinkingTokens); + }; + + readonly close = (): void => { + this.closeCalls += 1; + this.finish(); + }; + + [Symbol.asyncIterator](): AsyncIterator { + return { + next: () => { + if (this.queue.length > 0) { + const value = this.queue.shift(); + if (value) { + return Promise.resolve({ + done: false, + value, + }); + } + } + if (this.done) { + return Promise.resolve({ + done: true, + value: undefined, + }); + } + return new Promise((resolve) => { + this.resolvers.push(resolve); + }); + }, + }; + } +} + +interface Harness { + readonly layer: ReturnType; + readonly query: FakeClaudeQuery; + readonly getLastCreateQueryInput: () => + | { + readonly prompt: AsyncIterable; + readonly options: ClaudeQueryOptions; + } + | undefined; +} + +function makeHarness(config?: { + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: ClaudeCodeAdapterLiveOptions["nativeEventLogger"]; +}): Harness { + const query = new FakeClaudeQuery(); + let createInput: + | { + readonly prompt: AsyncIterable; + readonly options: ClaudeQueryOptions; + } + | undefined; + + const adapterOptions: ClaudeCodeAdapterLiveOptions = { + createQuery: (input) => { + createInput = input; + return query; + }, + ...(config?.nativeEventLogger + ? { + nativeEventLogger: config.nativeEventLogger, + } + : {}), + ...(config?.nativeEventLogPath + ? { + nativeEventLogPath: config.nativeEventLogPath, + } + : {}), + }; + + return { + layer: makeClaudeCodeAdapterLive(adapterOptions), + query, + getLastCreateQueryInput: () => createInput, + }; +} + +function makeDeterministicRandomService(seed = 0x1234_5678): { + nextIntUnsafe: () => number; + nextDoubleUnsafe: () => number; +} { + let state = seed >>> 0; + const nextIntUnsafe = (): number => { + state = (Math.imul(1_664_525, state) + 1_013_904_223) >>> 0; + return state; + }; + + return { + nextIntUnsafe, + nextDoubleUnsafe: () => nextIntUnsafe() / 0x1_0000_0000, + }; +} + +async function readFirstPromptText( + input: + | { + readonly prompt: AsyncIterable; + } + | undefined, +): Promise { + const iterator = input?.prompt[Symbol.asyncIterator](); + if (!iterator) { + return undefined; + } + const next = await iterator.next(); + if (next.done) { + return undefined; + } + const content = next.value.message.content[0]; + if (!content || content.type !== "text") { + return undefined; + } + return content.text; +} + +const THREAD_ID = ThreadId.makeUnsafe("thread-claude-1"); +const RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-claude-resume"); + +describe("ClaudeCodeAdapterLive", () => { + it.effect("returns validation error for non-claudeCode provider on startSession", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + const result = yield* adapter + .startSession({ threadId: THREAD_ID, provider: "codex", runtimeMode: "full-access" }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + if (result._tag !== "Failure") { + return; + } + assert.deepEqual( + result.failure, + new ProviderAdapterValidationError({ + provider: "claudeCode", + operation: "startSession", + issue: "Expected provider 'claudeCode' but received 'codex'.", + }), + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("derives bypass permission mode from full-access runtime policy", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.permissionMode, "bypassPermissions"); + assert.equal(createInput?.options.allowDangerouslySkipPermissions, true); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("keeps explicit claude permission mode over runtime-derived defaults", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + providerOptions: { + claudeCode: { + permissionMode: "plan", + }, + }, + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.permissionMode, "plan"); + assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("forwards claude effort levels into query options", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + modelOptions: { + claudeCode: { + effort: "max", + }, + }, + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.effort, "max"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("forwards Claude agent teams session options and env flags", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + providerOptions: { + claudeCode: { + experimentalAgentTeams: true, + agentProgressSummaries: true, + }, + }, + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.agentProgressSummaries, true); + assert.deepEqual(createInput?.options.settings, { teammateMode: "in-process" }); + assert.equal(createInput?.options.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS, "1"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("maps ultrathink to max effort and prefixes the prompt", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + modelOptions: { + claudeCode: { + effort: "ultrathink", + }, + }, + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "Investigate the edge cases", + attachments: [], + modelOptions: { + claudeCode: { + effort: "ultrathink", + }, + }, + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.effort, "max"); + const promptText = yield* Effect.promise(() => readFirstPromptText(createInput)); + assert.equal(promptText, "Ultrathink:\nInvestigate the edge cases"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("maps teammate idle and shutdown user messages into hook lifecycle events", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 8).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "create a small agent team", + attachments: [], + }); + + harness.query.emit({ + type: "user", + session_id: "sdk-session-team-user", + uuid: "user-team-user", + parent_tool_use_id: null, + teamName: "tiny-team", + message: { + role: "user", + content: [ + { + type: "text", + text: ` +{"type":"idle_notification","from":"researcher","timestamp":"2026-03-16T12:00:00.000Z","idleReason":"available"} + + + +{"type":"shutdown_approved","requestId":"shutdown-1","from":"researcher","timestamp":"2026-03-16T12:01:00.000Z"} + + + +{"type":"teammate_terminated","message":"executor has shut down."} +`, + }, + ], + }, + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const hookEvents = runtimeEvents.filter((event) => event.type === "hook.started"); + assert.equal(hookEvents.length, 3); + + const [idleEvent, shutdownEvent, terminatedEvent] = hookEvents; + assert.equal(idleEvent?.type, "hook.started"); + if (idleEvent?.type === "hook.started") { + assert.equal(idleEvent.payload.hookEvent, "TeammateIdle"); + assert.equal(idleEvent.payload.hookName, "idle_notification"); + assert.equal(idleEvent.payload.teammateName, "researcher"); + assert.equal(idleEvent.payload.teamName, "tiny-team"); + assert.equal(idleEvent.payload.agentColor, "blue"); + } + + assert.equal(shutdownEvent?.type, "hook.started"); + if (shutdownEvent?.type === "hook.started") { + assert.equal(shutdownEvent.payload.hookEvent, "SubagentStop"); + assert.equal(shutdownEvent.payload.hookName, "shutdown_approved"); + assert.equal(shutdownEvent.payload.teammateName, "researcher"); + assert.equal(shutdownEvent.payload.teamName, "tiny-team"); + assert.equal(shutdownEvent.payload.agentColor, "blue"); + } + + assert.equal(terminatedEvent?.type, "hook.started"); + if (terminatedEvent?.type === "hook.started") { + assert.equal(terminatedEvent.payload.hookEvent, "SubagentStop"); + assert.equal(terminatedEvent.payload.hookName, "teammate_terminated"); + assert.equal(terminatedEvent.payload.teammateName, "executor"); + assert.equal(terminatedEvent.payload.teamName, "tiny-team"); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("maps Claude stream/runtime messages to canonical provider runtime events", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 11).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + model: "claude-sonnet-4-5", + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-1", + uuid: "stream-1", + parent_tool_use_id: null, + event: { + type: "content_block_delta", + index: 0, + delta: { + type: "text_delta", + text: "Hi", + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-1", + uuid: "stream-2", + parent_tool_use_id: null, + event: { + type: "content_block_start", + index: 1, + content_block: { + type: "tool_use", + id: "tool-1", + name: "Bash", + input: { + command: "ls", + }, + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-1", + uuid: "stream-3", + parent_tool_use_id: null, + event: { + type: "content_block_stop", + index: 1, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-1", + uuid: "assistant-1", + parent_tool_use_id: null, + message: { + id: "assistant-message-1", + content: [{ type: "text", text: "Hi" }], + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-1", + uuid: "result-1", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + assert.deepEqual( + runtimeEvents.map((event) => event.type), + [ + "session.started", + "session.configured", + "session.state.changed", + "turn.started", + "thread.started", + "content.delta", + "item.started", + "item.completed", + "item.updated", + "item.completed", + "turn.completed", + ], + ); + + const turnStarted = runtimeEvents[3]; + assert.equal(turnStarted?.type, "turn.started"); + if (turnStarted?.type === "turn.started") { + assert.equal(String(turnStarted.turnId), String(turn.turnId)); + } + + const deltaEvent = runtimeEvents.find((event) => event.type === "content.delta"); + assert.equal(deltaEvent?.type, "content.delta"); + if (deltaEvent?.type === "content.delta") { + assert.equal(deltaEvent.payload.delta, "Hi"); + assert.equal(String(deltaEvent.turnId), String(turn.turnId)); + } + + const toolStarted = runtimeEvents.find((event) => event.type === "item.started"); + assert.equal(toolStarted?.type, "item.started"); + if (toolStarted?.type === "item.started") { + assert.equal(toolStarted.payload.itemType, "command_execution"); + } + + const turnCompleted = runtimeEvents[runtimeEvents.length - 1]; + assert.equal(turnCompleted?.type, "turn.completed"); + if (turnCompleted?.type === "turn.completed") { + assert.equal(String(turnCompleted.turnId), String(turn.turnId)); + assert.equal(turnCompleted.payload.state, "completed"); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("classifies Claude Task tool invocations as collaboration agent work", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 8).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "delegate this", + attachments: [], + }); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-task", + uuid: "stream-task-1", + parent_tool_use_id: null, + event: { + type: "content_block_start", + index: 0, + content_block: { + type: "tool_use", + id: "tool-task-1", + name: "Task", + input: { + description: "Review the database layer", + prompt: "Audit the SQL changes", + subagent_type: "code-reviewer", + team_name: "release-squad", + name: "db-reviewer", + }, + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-task", + uuid: "assistant-task-1", + parent_tool_use_id: null, + message: { + id: "assistant-message-task-1", + content: [{ type: "text", text: "Delegated" }], + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-task", + uuid: "result-task-1", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const toolStarted = runtimeEvents.find((event) => event.type === "item.started"); + assert.equal(toolStarted?.type, "item.started"); + if (toolStarted?.type === "item.started") { + assert.equal(toolStarted.payload.itemType, "collab_agent_tool_call"); + assert.equal(toolStarted.payload.title, "Subagent task"); + assert.equal(toolStarted.payload.toolUseId, "tool-task-1"); + assert.equal(toolStarted.payload.agentType, "code-reviewer"); + assert.equal(toolStarted.payload.teamName, "release-squad"); + assert.equal(toolStarted.payload.teammateName, "db-reviewer"); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("forwards Claude task progress summaries for subagent updates", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 5).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + }); + + harness.query.emit({ + type: "system", + subtype: "task_progress", + task_id: "task-subagent-1", + tool_use_id: "tool-task-1", + description: "Running background teammate", + summary: "Code reviewer checked the migration edge cases.", + usage: { + total_tokens: 123, + tool_uses: 4, + duration_ms: 987, + }, + session_id: "sdk-session-task-summary", + uuid: "task-progress-1", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const progressEvent = runtimeEvents.find((event) => event.type === "task.progress"); + assert.equal(progressEvent?.type, "task.progress"); + if (progressEvent?.type === "task.progress") { + assert.equal( + progressEvent.payload.summary, + "Code reviewer checked the migration edge cases.", + ); + assert.equal(progressEvent.payload.description, "Running background teammate"); + assert.equal(progressEvent.payload.toolUseId, "tool-task-1"); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect( + "emits completion only after turn result when assistant frames arrive before deltas", + () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-early-assistant", + uuid: "assistant-early", + parent_tool_use_id: null, + message: { + id: "assistant-message-early", + content: [ + { type: "tool_use", id: "tool-early", name: "Read", input: { path: "a.ts" } }, + ], + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-early-assistant", + uuid: "stream-early", + parent_tool_use_id: null, + event: { + type: "content_block_delta", + index: 0, + delta: { + type: "text_delta", + text: "Late text", + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-early-assistant", + uuid: "result-early", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + assert.deepEqual( + runtimeEvents.map((event) => event.type), + [ + "session.started", + "session.configured", + "session.state.changed", + "turn.started", + "thread.started", + "item.updated", + "content.delta", + "item.completed", + "turn.completed", + ], + ); + + const deltaIndex = runtimeEvents.findIndex((event) => event.type === "content.delta"); + const completedIndex = runtimeEvents.findIndex((event) => event.type === "item.completed"); + assert.equal(deltaIndex >= 0 && completedIndex >= 0 && deltaIndex < completedIndex, true); + + const deltaEvent = runtimeEvents[deltaIndex]; + assert.equal(deltaEvent?.type, "content.delta"); + if (deltaEvent?.type === "content.delta") { + assert.equal(deltaEvent.payload.delta, "Late text"); + assert.equal(String(deltaEvent.turnId), String(turn.turnId)); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }, + ); + + it.effect("falls back to assistant payload text when stream deltas are absent", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-fallback-text", + uuid: "assistant-fallback", + parent_tool_use_id: null, + message: { + id: "assistant-message-fallback", + content: [{ type: "text", text: "Fallback hello" }], + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-fallback-text", + uuid: "result-fallback", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + assert.deepEqual( + runtimeEvents.map((event) => event.type), + [ + "session.started", + "session.configured", + "session.state.changed", + "turn.started", + "thread.started", + "item.updated", + "content.delta", + "item.completed", + "turn.completed", + ], + ); + + const deltaEvent = runtimeEvents.find((event) => event.type === "content.delta"); + assert.equal(deltaEvent?.type, "content.delta"); + if (deltaEvent?.type === "content.delta") { + assert.equal(deltaEvent.payload.delta, "Fallback hello"); + assert.equal(String(deltaEvent.turnId), String(turn.turnId)); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("does not fabricate provider thread ids before first SDK session_id", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 5).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + }); + assert.equal(session.threadId, THREAD_ID); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + assert.equal(turn.threadId, THREAD_ID); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-thread-real", + uuid: "stream-thread-real", + parent_tool_use_id: null, + event: { + type: "message_start", + message: { + id: "msg-thread-real", + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-thread-real", + uuid: "result-thread-real", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + assert.deepEqual( + runtimeEvents.map((event) => event.type), + [ + "session.started", + "session.configured", + "session.state.changed", + "turn.started", + "thread.started", + ], + ); + + const sessionStarted = runtimeEvents[0]; + assert.equal(sessionStarted?.type, "session.started"); + if (sessionStarted?.type === "session.started") { + assert.equal(sessionStarted.threadId, THREAD_ID); + } + + const threadStarted = runtimeEvents[4]; + assert.equal(threadStarted?.type, "thread.started"); + if (threadStarted?.type === "thread.started") { + assert.equal(threadStarted.threadId, THREAD_ID); + assert.equal(threadStarted.payload.providerThreadId, "sdk-thread-real"); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("bridges approval request/response lifecycle through canUseTool", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "approval-required", + }); + + yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + + const createInput = harness.getLastCreateQueryInput(); + const canUseTool = createInput?.options.canUseTool; + assert.equal(typeof canUseTool, "function"); + if (!canUseTool) { + return; + } + + const permissionPromise = canUseTool( + "Bash", + { command: "pwd" }, + { + signal: new AbortController().signal, + suggestions: [ + { + type: "setMode", + mode: "default", + destination: "session", + }, + ], + toolUseID: "tool-use-1", + }, + ); + + const requested = yield* Stream.runHead(adapter.streamEvents); + assert.equal(requested._tag, "Some"); + if (requested._tag !== "Some") { + return; + } + assert.equal(requested.value.type, "request.opened"); + if (requested.value.type !== "request.opened") { + return; + } + const runtimeRequestId = requested.value.requestId; + assert.equal(typeof runtimeRequestId, "string"); + if (runtimeRequestId === undefined) { + return; + } + + yield* adapter.respondToRequest( + session.threadId, + ApprovalRequestId.makeUnsafe(runtimeRequestId), + "accept", + ); + + const resolved = yield* Stream.runHead(adapter.streamEvents); + assert.equal(resolved._tag, "Some"); + if (resolved._tag !== "Some") { + return; + } + assert.equal(resolved.value.type, "request.resolved"); + if (resolved.value.type !== "request.resolved") { + return; + } + assert.equal(resolved.value.requestId, requested.value.requestId); + assert.equal(resolved.value.payload.decision, "accept"); + + const permissionResult = yield* Effect.promise(() => permissionPromise); + assert.equal((permissionResult as PermissionResult).behavior, "allow"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("passes parsed resume cursor values to Claude query options", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + + const session = yield* adapter.startSession({ + threadId: RESUME_THREAD_ID, + provider: "claudeCode", + resumeCursor: { + threadId: "resume-thread-1", + resume: "550e8400-e29b-41d4-a716-446655440000", + resumeSessionAt: "assistant-99", + turnCount: 3, + }, + runtimeMode: "full-access", + }); + + assert.equal(session.threadId, RESUME_THREAD_ID); + assert.deepEqual(session.resumeCursor, { + threadId: String(RESUME_THREAD_ID), + resume: "550e8400-e29b-41d4-a716-446655440000", + resumeSessionAt: "assistant-99", + turnCount: 3, + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.resume, "550e8400-e29b-41d4-a716-446655440000"); + assert.equal(createInput?.options.resumeSessionAt, "assistant-99"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("does not synthesize resume session id from generated thread ids", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + }); + + assert.equal("resume" in (session.resumeCursor as Record), false); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.resume, undefined); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect( + "supports rollbackThread by trimming in-memory turns and preserving earlier turns", + () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + }); + + const firstTurn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "first", + attachments: [], + }); + + const firstCompletedFiber = yield* Stream.filter( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runHead, Effect.forkChild); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-rollback", + uuid: "result-first", + } as unknown as SDKMessage); + + const firstCompleted = yield* Fiber.join(firstCompletedFiber); + assert.equal(firstCompleted._tag, "Some"); + if (firstCompleted._tag === "Some" && firstCompleted.value.type === "turn.completed") { + assert.equal(String(firstCompleted.value.turnId), String(firstTurn.turnId)); + } + + const secondTurn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "second", + attachments: [], + }); + + const secondCompletedFiber = yield* Stream.filter( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runHead, Effect.forkChild); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-rollback", + uuid: "result-second", + } as unknown as SDKMessage); + + const secondCompleted = yield* Fiber.join(secondCompletedFiber); + assert.equal(secondCompleted._tag, "Some"); + if (secondCompleted._tag === "Some" && secondCompleted.value.type === "turn.completed") { + assert.equal(String(secondCompleted.value.turnId), String(secondTurn.turnId)); + } + + const threadBeforeRollback = yield* adapter.readThread(session.threadId); + assert.equal(threadBeforeRollback.turns.length, 2); + + const rolledBack = yield* adapter.rollbackThread(session.threadId, 1); + assert.equal(rolledBack.turns.length, 1); + assert.equal(rolledBack.turns[0]?.id, firstTurn.turnId); + + const threadAfterRollback = yield* adapter.readThread(session.threadId); + assert.equal(threadAfterRollback.turns.length, 1); + assert.equal(threadAfterRollback.turns[0]?.id, firstTurn.turnId); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }, + ); + + it.effect("updates model on sendTurn when model override is provided", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + model: "claude-opus-4-6", + attachments: [], + }); + + assert.deepEqual(harness.query.setModelCalls, ["claude-opus-4-6"]); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("writes provider-native observability records when enabled", () => { + const nativeEvents: Array<{ + event?: { + provider?: string; + method?: string; + threadId?: string; + turnId?: string; + }; + }> = []; + const harness = makeHarness({ + nativeEventLogger: { + filePath: "memory://claude-native-events", + write: (event) => { + nativeEvents.push(event as (typeof nativeEvents)[number]); + return Effect.void; + }, + close: () => Effect.void, + }, + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + const turnCompletedFiber = yield* Stream.filter( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runHead, Effect.forkChild); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-native-log", + uuid: "stream-native-log", + parent_tool_use_id: null, + event: { + type: "content_block_delta", + index: 0, + delta: { + type: "text_delta", + text: "hi", + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-native-log", + uuid: "result-native-log", + } as unknown as SDKMessage); + + const turnCompleted = yield* Fiber.join(turnCompletedFiber); + assert.equal(turnCompleted._tag, "Some"); + + assert.equal(nativeEvents.length > 0, true); + assert.equal( + nativeEvents.some((record) => record.event?.provider === "claudeCode"), + true, + ); + assert.equal( + nativeEvents.some( + (record) => record.event?.method === "claude/stream_event/content_block_delta/text_delta", + ), + true, + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); +}); diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts new file mode 100644 index 000000000..a1d898675 --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts @@ -0,0 +1,2428 @@ +/** + * ClaudeCodeAdapterLive - Scoped live implementation for the Claude Code provider adapter. + * + * Wraps `@anthropic-ai/claude-agent-sdk` query sessions behind the generic + * provider adapter contract and emits canonical runtime events. + * + * @module ClaudeCodeAdapterLive + */ +import { + type CanUseTool, + query, + type Options as ClaudeQueryOptions, + type PermissionMode, + type PermissionResult, + type PermissionUpdate, + type SDKMessage, + type SDKResultMessage, + type SDKUserMessage, +} from "@anthropic-ai/claude-agent-sdk"; +import { + ApprovalRequestId, + type CanonicalItemType, + type CanonicalRequestType, + EventId, + type ProviderApprovalDecision, + ProviderItemId, + type ProviderRuntimeEvent, + type ProviderRuntimeTurnStatus, + type ProviderSendTurnInput, + type ProviderSession, + RuntimeItemId, + RuntimeRequestId, + RuntimeTaskId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { applyClaudePromptEffortPrefix, getEffectiveClaudeCodeEffort } from "@t3tools/shared/model"; +import { Cause, DateTime, Deferred, Effect, Layer, Queue, Random, Ref, Stream } from "effect"; + +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; +import { ClaudeCodeAdapter, type ClaudeCodeAdapterShape } from "../Services/ClaudeCodeAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const PROVIDER = "claudeCode" as const; + +type PromptQueueItem = + | { + readonly type: "message"; + readonly message: SDKUserMessage; + } + | { + readonly type: "terminate"; + }; + +interface ClaudeResumeState { + readonly threadId?: ThreadId; + readonly resume?: string; + readonly resumeSessionAt?: string; + readonly turnCount?: number; +} + +interface ClaudeTurnState { + readonly turnId: TurnId; + readonly assistantItemId: string; + readonly startedAt: string; + readonly items: Array; + readonly messageCompleted: boolean; + readonly emittedTextDelta: boolean; + readonly fallbackAssistantText: string; +} + +interface PendingApproval { + readonly requestType: CanonicalRequestType; + readonly detail?: string; + readonly suggestions?: ReadonlyArray; + readonly decision: Deferred.Deferred; +} + +interface ToolInFlight { + readonly itemId: string; + readonly itemType: CanonicalItemType; + readonly toolName: string; + readonly title: string; + detail?: string; + metadata: ClaudeAgentMetadata; + inputJsonFragments: string[]; +} + +interface ClaudeSessionContext { + session: ProviderSession; + readonly promptQueue: Queue.Queue; + readonly query: ClaudeQueryRuntime; + readonly startedAt: string; + resumeSessionId: string | undefined; + readonly pendingApprovals: Map; + readonly turns: Array<{ + id: TurnId; + items: Array; + }>; + readonly inFlightTools: Map; + turnState: ClaudeTurnState | undefined; + lastAssistantUuid: string | undefined; + lastThreadStartedId: string | undefined; + stopped: boolean; +} + +interface ClaudeQueryRuntime extends AsyncIterable { + readonly interrupt: () => Promise; + readonly setModel: (model?: string) => Promise; + readonly setPermissionMode: (mode: PermissionMode) => Promise; + readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise; + readonly close: () => void; +} + +export interface ClaudeCodeAdapterLiveOptions { + readonly createQuery?: (input: { + readonly prompt: AsyncIterable; + readonly options: ClaudeQueryOptions; + }) => ClaudeQueryRuntime; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; +} + +interface ClaudeAgentMetadata { + agentId?: string; + agentName?: string; + agentColor?: string; + agentType?: string; + teamName?: string; + teammateName?: string; + parentSessionId?: string; + teammateMode?: string; + toolUseId?: string; + planModeRequired?: boolean; + awaitingLeaderApproval?: boolean; +} + +const CLAUDE_AGENT_TEAMS_TEAMMATE_MODE = "in-process" as const; + +interface ParsedClaudeTeammateUserEvent { + readonly hookEvent: "TeammateIdle" | "SubagentStop"; + readonly hookName: string; + readonly detail?: string; + readonly metadata: ClaudeAgentMetadata; +} + +function isUuid(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); +} + +function isSyntheticClaudeThreadId(value: string): boolean { + return value.startsWith("claude-thread-"); +} + +function toMessage(cause: unknown, fallback: string): string { + if (cause instanceof Error && cause.message.length > 0) { + return cause.message; + } + return fallback; +} + +function safeParseJson(json: string): Record | null { + const parsed = (() => { + try { + return JSON.parse(json); + } catch { + return null; + } + })(); + return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null; +} + +function asRuntimeItemId(value: string): RuntimeItemId { + return RuntimeItemId.makeUnsafe(value); +} + +function asCanonicalTurnId(value: TurnId): TurnId { + return value; +} + +function asRuntimeRequestId(value: ApprovalRequestId): RuntimeRequestId { + return RuntimeRequestId.makeUnsafe(value); +} + +function asTrimmedString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function asBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +function metadataFields(metadata: ClaudeAgentMetadata): ClaudeAgentMetadata { + return metadata; +} + +function mergeClaudeAgentMetadata( + ...parts: ReadonlyArray +): ClaudeAgentMetadata { + const metadata: ClaudeAgentMetadata = {}; + + for (const part of parts) { + if (!part) { + continue; + } + if (part.agentId) { + metadata.agentId = part.agentId; + } + if (part.agentName) { + metadata.agentName = part.agentName; + } + if (part.agentColor) { + metadata.agentColor = part.agentColor; + } + if (part.agentType) { + metadata.agentType = part.agentType; + } + if (part.teamName) { + metadata.teamName = part.teamName; + } + if (part.teammateName) { + metadata.teammateName = part.teammateName; + } + if (part.parentSessionId) { + metadata.parentSessionId = part.parentSessionId; + } + if (part.teammateMode) { + metadata.teammateMode = part.teammateMode; + } + if (part.toolUseId) { + metadata.toolUseId = part.toolUseId; + } + if (part.planModeRequired !== undefined) { + metadata.planModeRequired = part.planModeRequired; + } + if (part.awaitingLeaderApproval !== undefined) { + metadata.awaitingLeaderApproval = part.awaitingLeaderApproval; + } + } + + return metadata; +} + +function extractAgentMetadataFromRecord( + value: Record | undefined, + overrides?: ClaudeAgentMetadata, +): ClaudeAgentMetadata { + const agentId = asTrimmedString(value?.agent_id) ?? asTrimmedString(value?.agentId); + const agentName = asTrimmedString(value?.agent_name) ?? asTrimmedString(value?.agentName); + const agentColor = + asTrimmedString(value?.agent_color) ?? + asTrimmedString(value?.agentColor) ?? + asTrimmedString( + value?.routing && typeof value.routing === "object" + ? (value.routing as Record).senderColor + : undefined, + ); + const agentType = asTrimmedString(value?.agent_type) ?? asTrimmedString(value?.agentType); + const teamName = asTrimmedString(value?.team_name) ?? asTrimmedString(value?.teamName); + const teammateName = + asTrimmedString(value?.teammate_name) ?? asTrimmedString(value?.teammateName); + const parentSessionId = + asTrimmedString(value?.parent_session_id) ?? asTrimmedString(value?.parentSessionId); + const teammateMode = + asTrimmedString(value?.teammate_mode) ?? asTrimmedString(value?.teammateMode); + const toolUseId = asTrimmedString(value?.tool_use_id) ?? asTrimmedString(value?.toolUseId); + const planModeRequired = + asBoolean(value?.plan_mode_required) ?? asBoolean(value?.planModeRequired); + const awaitingLeaderApproval = + asBoolean(value?.awaitingLeaderApproval) ?? asBoolean(value?.awaiting_leader_approval); + + return mergeClaudeAgentMetadata( + { + ...(agentId ? { agentId } : {}), + ...(agentName ? { agentName } : {}), + ...(agentColor ? { agentColor } : {}), + ...(agentType ? { agentType } : {}), + ...(teamName ? { teamName } : {}), + ...(teammateName ? { teammateName } : {}), + ...(parentSessionId ? { parentSessionId } : {}), + ...(teammateMode ? { teammateMode } : {}), + ...(toolUseId ? { toolUseId } : {}), + ...(planModeRequired !== undefined ? { planModeRequired } : {}), + ...(awaitingLeaderApproval !== undefined ? { awaitingLeaderApproval } : {}), + }, + overrides, + ); +} + +function extractCollabAgentMetadata( + itemType: CanonicalItemType, + toolInput: Record, + toolUseId: string, +): ClaudeAgentMetadata { + if (itemType !== "collab_agent_tool_call") { + return {}; + } + + const base = extractAgentMetadataFromRecord(toolInput, { + toolUseId, + }); + const teammateName = + asTrimmedString(toolInput.name) ?? asTrimmedString(toolInput.recipient); + const agentType = asTrimmedString(toolInput.subagent_type); + + return mergeClaudeAgentMetadata(base, { + ...(teammateName ? { teammateName } : {}), + ...(agentType ? { agentType } : {}), + }); +} + +function toPermissionMode(value: unknown): PermissionMode | undefined { + switch (value) { + case "default": + case "acceptEdits": + case "bypassPermissions": + case "plan": + case "dontAsk": + return value; + default: + return undefined; + } +} + +function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undefined { + if (!resumeCursor || typeof resumeCursor !== "object") { + return undefined; + } + const cursor = resumeCursor as { + threadId?: unknown; + resume?: unknown; + sessionId?: unknown; + resumeSessionAt?: unknown; + turnCount?: unknown; + }; + + const threadIdCandidate = typeof cursor.threadId === "string" ? cursor.threadId : undefined; + const threadId = + threadIdCandidate && !isSyntheticClaudeThreadId(threadIdCandidate) + ? ThreadId.makeUnsafe(threadIdCandidate) + : undefined; + const resumeCandidate = + typeof cursor.resume === "string" + ? cursor.resume + : typeof cursor.sessionId === "string" + ? cursor.sessionId + : undefined; + const resume = resumeCandidate && isUuid(resumeCandidate) ? resumeCandidate : undefined; + const resumeSessionAt = + typeof cursor.resumeSessionAt === "string" ? cursor.resumeSessionAt : undefined; + const turnCountValue = typeof cursor.turnCount === "number" ? cursor.turnCount : undefined; + + return { + ...(threadId ? { threadId } : {}), + ...(resume ? { resume } : {}), + ...(resumeSessionAt ? { resumeSessionAt } : {}), + ...(turnCountValue !== undefined && Number.isInteger(turnCountValue) && turnCountValue >= 0 + ? { turnCount: turnCountValue } + : {}), + }; +} + +function classifyToolItemType(toolName: string): CanonicalItemType { + const normalized = toolName.toLowerCase(); + if ( + normalized === "task" || + normalized === "agent" || + normalized.includes("subagent") || + normalized.includes("sub-agent") || + normalized === "teamcreate" || + normalized === "teamdelete" || + normalized === "teamupdate" || + normalized === "sendmessage" || + normalized === "taskcreate" || + normalized === "taskupdate" || + normalized === "taskdelete" + ) { + return "collab_agent_tool_call"; + } + if ( + normalized.includes("bash") || + normalized.includes("command") || + normalized.includes("shell") || + normalized.includes("terminal") + ) { + return "command_execution"; + } + if ( + normalized.includes("edit") || + normalized.includes("write") || + normalized.includes("file") || + normalized.includes("patch") || + normalized.includes("replace") || + normalized.includes("create") || + normalized.includes("delete") + ) { + return "file_change"; + } + if (normalized.includes("mcp")) { + return "mcp_tool_call"; + } + return "dynamic_tool_call"; +} + +function classifyRequestType(toolName: string): CanonicalRequestType { + const normalized = toolName.toLowerCase(); + if (normalized === "read" || normalized.includes("read file") || normalized.includes("view")) { + return "file_read_approval"; + } + return classifyToolItemType(toolName) === "command_execution" + ? "command_execution_approval" + : "file_change_approval"; +} + +function summarizeToolRequest(toolName: string, input: Record): string { + const commandValue = input.command ?? input.cmd; + const command = typeof commandValue === "string" ? commandValue : undefined; + if (command && command.trim().length > 0) { + return `${toolName}: ${command.trim().slice(0, 400)}`; + } + + // Extract meaningful info from team tools + const normalizedName = toolName.toLowerCase(); + if (normalizedName === "sendmessage") { + // SDK schema: { type, recipient, content, summary } + const recipient = + typeof input.recipient === "string" ? input.recipient + : typeof input.to === "string" ? input.to + : undefined; + const content = + typeof input.content === "string" ? input.content + : typeof input.summary === "string" ? input.summary + : typeof input.message === "string" ? input.message + : undefined; + if (recipient && content) { + return `${toolName} to ${recipient}: ${content.slice(0, 300)}`; + } + if (recipient) { + return `${toolName} to ${recipient}`; + } + } + if (normalizedName === "teamcreate" || normalizedName === "teamdelete") { + const teamName = typeof input.team_name === "string" ? input.team_name : undefined; + if (teamName) { + return `${toolName}: ${teamName}`; + } + } + + const serialized = JSON.stringify(input); + if (serialized.length <= 400) { + return `${toolName}: ${serialized}`; + } + return `${toolName}: ${serialized.slice(0, 397)}...`; +} + +function titleForTool(itemType: CanonicalItemType): string { + switch (itemType) { + case "command_execution": + return "Command run"; + case "file_change": + return "File change"; + case "mcp_tool_call": + return "MCP tool call"; + case "collab_agent_tool_call": + return "Subagent task"; + case "dynamic_tool_call": + return "Tool call"; + default: + return "Item"; + } +} + +function extractTextContentFromClaudeUserMessage(message: SDKUserMessage): string { + const content = (message.message as { content?: unknown }).content; + + if (typeof content === "string") { + return content; + } + + if (!Array.isArray(content)) { + return ""; + } + + const fragments: string[] = []; + for (const block of content) { + if (typeof block === "string") { + fragments.push(block); + continue; + } + if (!block || typeof block !== "object") { + continue; + } + const record = block as { type?: unknown; text?: unknown }; + if (record.type === "text" && typeof record.text === "string") { + fragments.push(record.text); + } + } + + return fragments.join("\n"); +} + +function parseTeammateMessageAttributes(rawAttributes: string): Record { + const attributes: Record = {}; + const attributePattern = /([A-Za-z_:][\w:.-]*)="([^"]*)"/g; + + for (const match of rawAttributes.matchAll(attributePattern)) { + const key = match[1]; + const value = match[2]; + if (key && value !== undefined) { + attributes[key] = value; + } + } + + return attributes; +} + +function explicitTeammateNameFromRecord( + record: Record | undefined, +): string | undefined { + if (!record) { + return undefined; + } + const nestedTeammate = record.teammate; + const nestedAgent = record.agent; + const teammateRecord = + nestedTeammate && typeof nestedTeammate === "object" + ? (nestedTeammate as Record) + : undefined; + const agentRecord = + nestedAgent && typeof nestedAgent === "object" + ? (nestedAgent as Record) + : undefined; + return ( + asTrimmedString(record.teammateName) ?? + asTrimmedString(record.teammate_name) ?? + asTrimmedString(record.agentName) ?? + asTrimmedString(record.agent_name) ?? + asTrimmedString(record.displayName) ?? + asTrimmedString(record.display_name) ?? + asTrimmedString(record.name) ?? + asTrimmedString(teammateRecord?.displayName) ?? + asTrimmedString(teammateRecord?.display_name) ?? + asTrimmedString(teammateRecord?.name) ?? + asTrimmedString(agentRecord?.displayName) ?? + asTrimmedString(agentRecord?.display_name) ?? + asTrimmedString(agentRecord?.name) + ); +} + +function teammateNameFromTerminationMessage(value: unknown): string | undefined { + const message = asTrimmedString(value); + if (!message) { + return undefined; + } + const match = /^(?.+?) has shut down\.?$/i.exec(message); + return match?.groups?.name?.trim(); +} + +function parseClaudeTeammateUserEvents( + message: SDKUserMessage, +): ReadonlyArray { + const textContent = extractTextContentFromClaudeUserMessage(message); + if (!textContent) { + return []; + } + + const baseMetadata = extractAgentMetadataFromRecord( + message as unknown as Record, + ); + const blockPattern = + /[^>]*)>(?[\s\S]*?)<\/teammate-message>/g; + const parsedEvents: ParsedClaudeTeammateUserEvent[] = []; + + for (const match of textContent.matchAll(blockPattern)) { + const attributes = parseTeammateMessageAttributes(match.groups?.attributes ?? ""); + const bodyText = match.groups?.body?.trim(); + if (!bodyText) { + continue; + } + + let bodyRecord: Record | undefined; + try { + const parsed = JSON.parse(bodyText); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + bodyRecord = parsed as Record; + } + } catch { + bodyRecord = undefined; + } + + const teammateId = asTrimmedString(attributes.teammate_id); + const color = asTrimmedString(attributes.color); + const summary = asTrimmedString(attributes.summary); + const eventType = asTrimmedString(bodyRecord?.type); + const from = asTrimmedString(bodyRecord?.from); + const terminatedTeammate = teammateNameFromTerminationMessage(bodyRecord?.message); + const explicitTeammateName = explicitTeammateNameFromRecord(bodyRecord); + const teammateNameCandidate = + explicitTeammateName ?? + terminatedTeammate ?? + (from && from !== "team-lead" && from !== "system" ? from : undefined) ?? + undefined; + const agentIdCandidate = + asTrimmedString(bodyRecord?.agentId) ?? + asTrimmedString(bodyRecord?.agent_id) ?? + (teammateId && teammateId !== "team-lead" && teammateId !== "system" + ? teammateId + : undefined) ?? + teammateNameCandidate; + const metadata = mergeClaudeAgentMetadata( + baseMetadata, + extractAgentMetadataFromRecord(bodyRecord), + { + ...(agentIdCandidate ? { agentId: agentIdCandidate } : {}), + ...(teammateNameCandidate + ? { + agentName: teammateNameCandidate, + teammateName: teammateNameCandidate, + } + : {}), + ...(color ? { agentColor: color } : {}), + }, + ); + + switch (eventType) { + case "idle_notification": { + const detail = summary ?? asTrimmedString(bodyRecord?.idleReason); + parsedEvents.push({ + hookEvent: "TeammateIdle", + hookName: "idle_notification", + metadata, + ...(detail ? { detail } : {}), + }); + break; + } + case "shutdown_approved": + case "teammate_terminated": { + const detail = + summary ?? asTrimmedString(bodyRecord?.reason) ?? asTrimmedString(bodyRecord?.message); + parsedEvents.push({ + hookEvent: "SubagentStop", + hookName: eventType, + metadata, + ...(detail ? { detail } : {}), + }); + break; + } + default: + break; + } + } + + return parsedEvents; +} + +function buildUserMessage(input: ProviderSendTurnInput, sessionId?: string): SDKUserMessage { + const fragments: string[] = []; + + if (input.input && input.input.trim().length > 0) { + fragments.push(input.input.trim()); + } + + for (const attachment of input.attachments ?? []) { + if (attachment.type === "image") { + fragments.push( + `Attached image: ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes).`, + ); + } + } + + const text = applyClaudePromptEffortPrefix( + fragments.join("\n\n"), + input.modelOptions?.claudeCode?.effort ?? null, + ); + + return { + type: "user", + session_id: sessionId ?? "", + parent_tool_use_id: null, + message: { + role: "user", + content: [{ type: "text", text }], + }, + } as SDKUserMessage; +} + +function turnStatusFromResult(result: SDKResultMessage): ProviderRuntimeTurnStatus { + if (result.subtype === "success") { + return "completed"; + } + + const errors = result.errors.join(" ").toLowerCase(); + if (errors.includes("interrupt")) { + return "interrupted"; + } + if (errors.includes("cancel")) { + return "cancelled"; + } + return "failed"; +} + +function streamKindFromDeltaType(deltaType: string): "assistant_text" | "reasoning_text" { + return deltaType.includes("thinking") ? "reasoning_text" : "assistant_text"; +} + +function providerThreadRef( + context: ClaudeSessionContext, +): { readonly providerThreadId: string } | {} { + return context.resumeSessionId ? { providerThreadId: context.resumeSessionId } : {}; +} + +function extractAssistantText(message: SDKMessage): string { + if (message.type !== "assistant") { + return ""; + } + + const content = (message.message as { content?: unknown } | undefined)?.content; + if (!Array.isArray(content)) { + return ""; + } + + const fragments: string[] = []; + for (const block of content) { + if (!block || typeof block !== "object") { + continue; + } + const candidate = block as { type?: unknown; text?: unknown }; + if ( + candidate.type === "text" && + typeof candidate.text === "string" && + candidate.text.length > 0 + ) { + fragments.push(candidate.text); + } + } + + return fragments.join(""); +} + +function toSessionError( + threadId: ThreadId, + cause: unknown, +): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined { + const normalized = toMessage(cause, "").toLowerCase(); + if (normalized.includes("unknown session") || normalized.includes("not found")) { + return new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + cause, + }); + } + if (normalized.includes("closed")) { + return new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId, + cause, + }); + } + return undefined; +} + +function toRequestError(threadId: ThreadId, method: string, cause: unknown): ProviderAdapterError { + const sessionError = toSessionError(threadId, cause); + if (sessionError) { + return sessionError; + } + return new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: toMessage(cause, `${method} failed`), + cause, + }); +} + +function sdkMessageType(value: unknown): string | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const record = value as { type?: unknown }; + return typeof record.type === "string" ? record.type : undefined; +} + +function sdkMessageSubtype(value: unknown): string | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const record = value as { subtype?: unknown }; + return typeof record.subtype === "string" ? record.subtype : undefined; +} + +function sdkNativeMethod(message: SDKMessage): string { + const subtype = sdkMessageSubtype(message); + if (subtype) { + return `claude/${message.type}/${subtype}`; + } + + if (message.type === "stream_event") { + const streamType = sdkMessageType(message.event); + if (streamType) { + const deltaType = + streamType === "content_block_delta" + ? sdkMessageType((message.event as { delta?: unknown }).delta) + : undefined; + if (deltaType) { + return `claude/${message.type}/${streamType}/${deltaType}`; + } + return `claude/${message.type}/${streamType}`; + } + } + + return `claude/${message.type}`; +} + +function sdkNativeItemId(message: SDKMessage): string | undefined { + if (message.type === "assistant") { + const maybeId = (message.message as { id?: unknown }).id; + if (typeof maybeId === "string") { + return maybeId; + } + return undefined; + } + + if (message.type === "stream_event") { + const event = message.event as { + type?: unknown; + content_block?: { id?: unknown }; + }; + if (event.type === "content_block_start" && typeof event.content_block?.id === "string") { + return event.content_block.id; + } + } + + return undefined; +} + +function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { + return Effect.gen(function* () { + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { + stream: "native", + }) + : undefined); + + const createQuery = + options?.createQuery ?? + ((input: { + readonly prompt: AsyncIterable; + readonly options: ClaudeQueryOptions; + }) => query({ prompt: input.prompt, options: input.options }) as ClaudeQueryRuntime); + + const sessions = new Map(); + const runtimeEventQueue = yield* Queue.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => + Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid); + + const logNativeSdkMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (!nativeEventLogger) { + return; + } + + const observedAt = new Date().toISOString(); + const itemId = sdkNativeItemId(message); + + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: + "uuid" in message && typeof message.uuid === "string" + ? message.uuid + : crypto.randomUUID(), + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method: sdkNativeMethod(message), + ...(typeof message.session_id === "string" + ? { providerThreadId: message.session_id } + : {}), + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + ...(itemId ? { itemId: ProviderItemId.makeUnsafe(itemId) } : {}), + payload: message, + }, + }, + null, + ); + }); + + const snapshotThread = ( + context: ClaudeSessionContext, + ): Effect.Effect< + { + threadId: ThreadId; + turns: ReadonlyArray<{ + id: TurnId; + items: ReadonlyArray; + }>; + }, + ProviderAdapterValidationError + > => + Effect.gen(function* () { + const threadId = context.session.threadId; + if (!threadId) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "readThread", + issue: "Session thread id is not initialized yet.", + }); + } + return { + threadId, + turns: context.turns.map((turn) => ({ + id: turn.id, + items: [...turn.items], + })), + }; + }); + + const updateResumeCursor = (context: ClaudeSessionContext): Effect.Effect => + Effect.gen(function* () { + const threadId = context.session.threadId; + if (!threadId) return; + + const resumeCursor = { + threadId, + ...(context.resumeSessionId ? { resume: context.resumeSessionId } : {}), + ...(context.lastAssistantUuid ? { resumeSessionAt: context.lastAssistantUuid } : {}), + turnCount: context.turns.length, + }; + + context.session = { + ...context.session, + resumeCursor, + updatedAt: yield* nowIso, + }; + }); + + const ensureThreadId = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (typeof message.session_id !== "string" || message.session_id.length === 0) { + return; + } + const nextThreadId = message.session_id; + context.resumeSessionId = message.session_id; + yield* updateResumeCursor(context); + + if (context.lastThreadStartedId !== nextThreadId) { + context.lastThreadStartedId = nextThreadId; + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "thread.started", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + payload: { + providerThreadId: nextThreadId, + }, + providerRefs: {}, + raw: { + source: "claude.sdk.message", + method: "claude/thread/started", + payload: { + session_id: message.session_id, + }, + }, + }); + } + }); + + const emitRuntimeError = ( + context: ClaudeSessionContext, + message: string, + cause?: unknown, + ): Effect.Effect => + Effect.gen(function* () { + if (cause !== undefined) { + void cause; + } + const turnState = context.turnState; + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "runtime.error", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(turnState ? { turnId: asCanonicalTurnId(turnState.turnId) } : {}), + payload: { + message, + class: "provider_error", + ...(cause !== undefined ? { detail: cause } : {}), + }, + providerRefs: { + ...providerThreadRef(context), + ...(turnState ? { providerTurnId: String(turnState.turnId) } : {}), + }, + }); + }); + + const emitRuntimeWarning = ( + context: ClaudeSessionContext, + message: string, + detail?: unknown, + ): Effect.Effect => + Effect.gen(function* () { + const turnState = context.turnState; + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "runtime.warning", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(turnState ? { turnId: asCanonicalTurnId(turnState.turnId) } : {}), + payload: { + message, + ...(detail !== undefined ? { detail } : {}), + }, + providerRefs: { + ...providerThreadRef(context), + ...(turnState ? { providerTurnId: String(turnState.turnId) } : {}), + }, + }); + }); + + const completeTurn = ( + context: ClaudeSessionContext, + status: ProviderRuntimeTurnStatus, + errorMessage?: string, + result?: SDKResultMessage, + ): Effect.Effect => + Effect.gen(function* () { + // Clear any stale in-flight tools from interrupted content blocks + context.inFlightTools.clear(); + const turnState = context.turnState; + if (!turnState) { + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.completed", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + payload: { + state: status, + ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}), + ...(result?.usage ? { usage: result.usage } : {}), + ...(result?.modelUsage ? { modelUsage: result.modelUsage } : {}), + ...(typeof result?.total_cost_usd === "number" + ? { totalCostUsd: result.total_cost_usd } + : {}), + ...(errorMessage ? { errorMessage } : {}), + }, + providerRefs: {}, + }); + return; + } + + if (!turnState.messageCompleted) { + if (!turnState.emittedTextDelta && turnState.fallbackAssistantText.length > 0) { + const deltaStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "content.delta", + eventId: deltaStamp.eventId, + provider: PROVIDER, + createdAt: deltaStamp.createdAt, + threadId: context.session.threadId, + turnId: turnState.turnId, + itemId: asRuntimeItemId(turnState.assistantItemId), + payload: { + streamKind: "assistant_text", + delta: turnState.fallbackAssistantText, + }, + providerRefs: { + ...providerThreadRef(context), + providerTurnId: String(turnState.turnId), + providerItemId: ProviderItemId.makeUnsafe(turnState.assistantItemId), + }, + }); + } + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.completed", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + itemId: asRuntimeItemId(turnState.assistantItemId), + threadId: context.session.threadId, + turnId: turnState.turnId, + payload: { + itemType: "assistant_message", + status: "completed", + title: "Assistant message", + }, + providerRefs: { + ...providerThreadRef(context), + providerTurnId: turnState.turnId, + providerItemId: ProviderItemId.makeUnsafe(turnState.assistantItemId), + }, + }); + } + + context.turns.push({ + id: turnState.turnId, + items: [...turnState.items], + }); + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.completed", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + turnId: turnState.turnId, + payload: { + state: status, + ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}), + ...(result?.usage ? { usage: result.usage } : {}), + ...(result?.modelUsage ? { modelUsage: result.modelUsage } : {}), + ...(typeof result?.total_cost_usd === "number" + ? { totalCostUsd: result.total_cost_usd } + : {}), + ...(errorMessage ? { errorMessage } : {}), + }, + providerRefs: { + ...providerThreadRef(context), + providerTurnId: turnState.turnId, + }, + }); + + const updatedAt = yield* nowIso; + context.turnState = undefined; + context.session = { + ...context.session, + status: "ready", + activeTurnId: undefined, + updatedAt, + ...(status === "failed" && errorMessage ? { lastError: errorMessage } : {}), + }; + yield* updateResumeCursor(context); + }); + + const handleStreamEvent = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (message.type !== "stream_event") { + return; + } + + const { event } = message; + + if (event.type === "content_block_delta") { + // Accumulate tool input JSON fragments for later use + if ( + (event.delta as { type?: string }).type === "input_json_delta" && + typeof (event.delta as { partial_json?: unknown }).partial_json === "string" + ) { + const tool = context.inFlightTools.get(event.index); + if (tool) { + tool.inputJsonFragments.push( + (event.delta as { partial_json: string }).partial_json, + ); + } + } + + if ( + event.delta.type === "text_delta" && + event.delta.text.length > 0 && + context.turnState + ) { + if (!context.turnState.emittedTextDelta) { + context.turnState = { + ...context.turnState, + emittedTextDelta: true, + }; + } + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "content.delta", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + turnId: context.turnState.turnId, + itemId: asRuntimeItemId(context.turnState.assistantItemId), + payload: { + streamKind: streamKindFromDeltaType(event.delta.type), + delta: event.delta.text, + }, + providerRefs: { + ...providerThreadRef(context), + providerTurnId: context.turnState.turnId, + providerItemId: ProviderItemId.makeUnsafe(context.turnState.assistantItemId), + }, + raw: { + source: "claude.sdk.message", + method: "claude/stream_event/content_block_delta", + payload: message, + }, + }); + } + return; + } + + if (event.type === "content_block_start") { + const { index, content_block: block } = event; + if ( + block.type !== "tool_use" && + block.type !== "server_tool_use" && + block.type !== "mcp_tool_use" + ) { + return; + } + + const toolName = block.name; + const itemType = classifyToolItemType(toolName); + const toolInput = + typeof block.input === "object" && block.input !== null + ? (block.input as Record) + : {}; + const itemId = block.id; + const detail = summarizeToolRequest(toolName, toolInput); + const metadata = extractCollabAgentMetadata(itemType, toolInput, itemId); + + const tool: ToolInFlight = { + itemId, + itemType, + toolName, + title: titleForTool(itemType), + detail, + metadata, + inputJsonFragments: [], + }; + context.inFlightTools.set(index, tool); + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.started", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + itemId: asRuntimeItemId(tool.itemId), + payload: { + itemType: tool.itemType, + status: "inProgress", + title: tool.title, + ...(tool.detail ? { detail: tool.detail } : {}), + ...metadataFields(tool.metadata), + data: { + toolName: tool.toolName, + input: toolInput, + }, + }, + providerRefs: { + ...providerThreadRef(context), + ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), + providerItemId: ProviderItemId.makeUnsafe(tool.itemId), + }, + raw: { + source: "claude.sdk.message", + method: "claude/stream_event/content_block_start", + payload: message, + }, + }); + return; + } + + if (event.type === "content_block_stop") { + const { index } = event; + const tool = context.inFlightTools.get(index); + if (!tool) { + return; + } + context.inFlightTools.delete(index); + + // Re-compute detail and metadata from accumulated input JSON + if (tool.inputJsonFragments.length > 0) { + const parsedInput = safeParseJson(tool.inputJsonFragments.join("")); + if (parsedInput) { + tool.detail = summarizeToolRequest(tool.toolName, parsedInput); + if (tool.itemType === "collab_agent_tool_call") { + tool.metadata = extractCollabAgentMetadata(tool.itemType, parsedInput, tool.itemId); + } + } + } + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.completed", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + itemId: asRuntimeItemId(tool.itemId), + payload: { + itemType: tool.itemType, + status: "completed", + title: tool.title, + ...(tool.detail ? { detail: tool.detail } : {}), + ...metadataFields(tool.metadata), + }, + providerRefs: { + ...providerThreadRef(context), + ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), + providerItemId: ProviderItemId.makeUnsafe(tool.itemId), + }, + raw: { + source: "claude.sdk.message", + method: "claude/stream_event/content_block_stop", + payload: message, + }, + }); + } + }); + + const handleAssistantMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (message.type !== "assistant") { + return; + } + + // Auto-start a synthetic turn for assistant messages that arrive without + // an active turn (e.g., teammate responses between user prompts). + if (!context.turnState) { + const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4); + const assistantItemId = yield* Random.nextUUIDv4; + const startedAt = yield* nowIso; + context.turnState = { + turnId, + assistantItemId, + startedAt, + items: [], + messageCompleted: false, + emittedTextDelta: false, + fallbackAssistantText: "", + }; + context.session = { + ...context.session, + status: "running", + activeTurnId: turnId, + updatedAt: startedAt, + }; + const turnStartedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.started", + eventId: turnStartedStamp.eventId, + provider: PROVIDER, + createdAt: turnStartedStamp.createdAt, + threadId: context.session.threadId, + turnId, + payload: {}, + providerRefs: { + ...providerThreadRef(context), + providerTurnId: turnId, + }, + raw: { + source: "claude.sdk.message", + method: "claude/synthetic-turn-start", + payload: {}, + }, + }); + } + + if (context.turnState) { + context.turnState.items.push(message.message); + const fallbackAssistantText = extractAssistantText(message); + if ( + fallbackAssistantText.length > 0 && + fallbackAssistantText !== context.turnState.fallbackAssistantText + ) { + context.turnState = { + ...context.turnState, + fallbackAssistantText, + }; + } + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.updated", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + turnId: context.turnState.turnId, + itemId: asRuntimeItemId(context.turnState.assistantItemId), + payload: { + itemType: "assistant_message", + status: "inProgress", + title: "Assistant message", + data: message.message, + }, + providerRefs: { + ...providerThreadRef(context), + providerTurnId: context.turnState.turnId, + providerItemId: ProviderItemId.makeUnsafe(context.turnState.assistantItemId), + }, + raw: { + source: "claude.sdk.message", + method: "claude/assistant", + payload: message, + }, + }); + } + + context.lastAssistantUuid = message.uuid; + yield* updateResumeCursor(context); + }); + + const handleResultMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (message.type !== "result") { + return; + } + + const status = turnStatusFromResult(message); + const errorMessage = message.subtype === "success" ? undefined : message.errors[0]; + + if (status === "failed") { + yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed."); + } + + yield* completeTurn(context, status, errorMessage, message); + }); + + const handleSystemMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (message.type !== "system") { + return; + } + + const stamp = yield* makeEventStamp(); + const messageRecord = message as unknown as Record; + const baseMetadata = extractAgentMetadataFromRecord(messageRecord); + const base = { + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + providerRefs: { + ...providerThreadRef(context), + ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), + }, + raw: { + source: "claude.sdk.message" as const, + method: sdkNativeMethod(message), + messageType: `${message.type}:${message.subtype}`, + payload: message, + }, + }; + + switch (message.subtype) { + case "init": + yield* offerRuntimeEvent({ + ...base, + type: "session.configured", + payload: { + config: message as Record, + }, + }); + return; + case "status": + yield* offerRuntimeEvent({ + ...base, + type: "session.state.changed", + payload: { + state: message.status === "compacting" ? "waiting" : "running", + reason: `status:${message.status ?? "active"}`, + detail: message, + }, + }); + return; + case "compact_boundary": + yield* offerRuntimeEvent({ + ...base, + type: "thread.state.changed", + payload: { + state: "compacted", + detail: message, + }, + }); + return; + case "hook_started": + yield* offerRuntimeEvent({ + ...base, + type: "hook.started", + payload: { + hookId: message.hook_id, + hookName: message.hook_name, + hookEvent: message.hook_event, + ...metadataFields(baseMetadata), + }, + }); + return; + case "hook_progress": + yield* offerRuntimeEvent({ + ...base, + type: "hook.progress", + payload: { + hookId: message.hook_id, + output: message.output, + stdout: message.stdout, + stderr: message.stderr, + ...metadataFields(baseMetadata), + }, + }); + return; + case "hook_response": + yield* offerRuntimeEvent({ + ...base, + type: "hook.completed", + payload: { + hookId: message.hook_id, + outcome: message.outcome, + output: message.output, + stdout: message.stdout, + stderr: message.stderr, + ...(typeof message.exit_code === "number" ? { exitCode: message.exit_code } : {}), + ...metadataFields(baseMetadata), + }, + }); + return; + case "task_started": + yield* offerRuntimeEvent({ + ...base, + type: "task.started", + payload: { + taskId: RuntimeTaskId.makeUnsafe(message.task_id), + description: message.description, + ...(message.task_type ? { taskType: message.task_type } : {}), + ...metadataFields(baseMetadata), + }, + }); + return; + case "task_progress": + yield* offerRuntimeEvent({ + ...base, + type: "task.progress", + payload: { + taskId: RuntimeTaskId.makeUnsafe(message.task_id), + description: message.description, + ...(message.summary ? { summary: message.summary } : {}), + ...(message.usage ? { usage: message.usage } : {}), + ...(message.last_tool_name ? { lastToolName: message.last_tool_name } : {}), + ...metadataFields(baseMetadata), + }, + }); + return; + case "task_notification": + yield* offerRuntimeEvent({ + ...base, + type: "task.completed", + payload: { + taskId: RuntimeTaskId.makeUnsafe(message.task_id), + status: message.status, + ...(message.summary ? { summary: message.summary } : {}), + ...(message.usage ? { usage: message.usage } : {}), + ...metadataFields(baseMetadata), + }, + }); + return; + case "local_command_output": + case "elicitation_complete": + return; + case "files_persisted": + yield* offerRuntimeEvent({ + ...base, + type: "files.persisted", + payload: { + files: Array.isArray(message.files) + ? message.files.map((file: { filename: string; file_id: string }) => ({ + filename: file.filename, + fileId: file.file_id, + })) + : [], + ...(Array.isArray(message.failed) + ? { + failed: message.failed.map((entry: { filename: string; error: string }) => ({ + filename: entry.filename, + error: entry.error, + })), + } + : {}), + }, + }); + return; + default: + const subtype = String((message as { subtype?: unknown }).subtype ?? "unknown"); + yield* emitRuntimeWarning( + context, + `Unhandled Claude system message subtype '${subtype}'.`, + message, + ); + return; + } + }); + + const handleSdkTelemetryMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + const stamp = yield* makeEventStamp(); + const base = { + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + providerRefs: { + ...providerThreadRef(context), + ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), + }, + raw: { + source: "claude.sdk.message" as const, + method: sdkNativeMethod(message), + messageType: message.type, + payload: message, + }, + }; + + if (message.type === "tool_progress") { + const metadata = extractAgentMetadataFromRecord( + message as unknown as Record, + ); + yield* offerRuntimeEvent({ + ...base, + type: "tool.progress", + payload: { + toolUseId: message.tool_use_id, + toolName: message.tool_name, + elapsedSeconds: message.elapsed_time_seconds, + ...(message.task_id ? { summary: `task:${message.task_id}` } : {}), + ...metadataFields(metadata), + }, + }); + return; + } + + if (message.type === "tool_use_summary") { + yield* offerRuntimeEvent({ + ...base, + type: "tool.summary", + payload: { + summary: message.summary, + ...(message.preceding_tool_use_ids.length > 0 + ? { precedingToolUseIds: message.preceding_tool_use_ids } + : {}), + }, + }); + return; + } + + if (message.type === "auth_status") { + yield* offerRuntimeEvent({ + ...base, + type: "auth.status", + payload: { + isAuthenticating: message.isAuthenticating, + output: message.output, + ...(message.error ? { error: message.error } : {}), + }, + }); + return; + } + + if (message.type === "rate_limit_event") { + yield* offerRuntimeEvent({ + ...base, + type: "account.rate-limits.updated", + payload: { + rateLimits: message, + }, + }); + return; + } + }); + + const handleUserMessage = ( + context: ClaudeSessionContext, + message: SDKUserMessage, + ): Effect.Effect => + Effect.gen(function* () { + const parsedEvents = parseClaudeTeammateUserEvents(message); + if (parsedEvents.length === 0) { + return; + } + + for (const parsedEvent of parsedEvents) { + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "hook.started", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + providerRefs: { + ...providerThreadRef(context), + ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), + }, + raw: { + source: "claude.sdk.message" as const, + method: "claude/user/teammate-message", + messageType: message.type, + payload: message, + }, + payload: { + hookId: crypto.randomUUID(), + hookName: parsedEvent.hookName, + hookEvent: parsedEvent.hookEvent, + ...(parsedEvent.detail ? { detail: parsedEvent.detail } : {}), + ...metadataFields(parsedEvent.metadata), + }, + }); + } + }); + + const handleSdkMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + yield* logNativeSdkMessage(context, message); + yield* ensureThreadId(context, message); + + switch (message.type) { + case "stream_event": + yield* handleStreamEvent(context, message); + return; + case "user": + yield* handleUserMessage(context, message); + return; + case "assistant": + yield* handleAssistantMessage(context, message); + return; + case "result": + yield* handleResultMessage(context, message); + return; + case "system": + yield* handleSystemMessage(context, message); + return; + case "tool_progress": + case "tool_use_summary": + case "auth_status": + case "rate_limit_event": + yield* handleSdkTelemetryMessage(context, message); + return; + case "prompt_suggestion": + return; + default: + const messageType = String((message as { type?: unknown }).type ?? "unknown"); + yield* emitRuntimeWarning( + context, + `Unhandled Claude SDK message type '${messageType}'.`, + message, + ); + return; + } + }); + + const runSdkStream = (context: ClaudeSessionContext): Effect.Effect => + Stream.fromAsyncIterable(context.query, (cause) => cause).pipe( + Stream.takeWhile(() => !context.stopped), + Stream.runForEach((message) => handleSdkMessage(context, message)), + Effect.catchCause((cause) => + Effect.gen(function* () { + if (Cause.hasInterruptsOnly(cause) || context.stopped) { + return; + } + const message = toMessage(Cause.squash(cause), "Claude runtime stream failed."); + yield* emitRuntimeError(context, message, cause); + yield* completeTurn(context, "failed", message); + }), + ), + ); + + const stopSessionInternal = ( + context: ClaudeSessionContext, + options?: { readonly emitExitEvent?: boolean }, + ): Effect.Effect => + Effect.gen(function* () { + if (context.stopped) return; + + context.stopped = true; + + for (const [requestId, pending] of context.pendingApprovals) { + yield* Deferred.succeed(pending.decision, "cancel"); + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "request.resolved", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + requestId: asRuntimeRequestId(requestId), + payload: { + requestType: pending.requestType, + decision: "cancel", + }, + providerRefs: { + ...providerThreadRef(context), + ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), + providerRequestId: requestId, + }, + }); + } + context.pendingApprovals.clear(); + + if (context.turnState) { + yield* completeTurn(context, "interrupted", "Session stopped."); + } + + yield* Queue.shutdown(context.promptQueue); + + context.query.close(); + + const updatedAt = yield* nowIso; + context.session = { + ...context.session, + status: "closed", + activeTurnId: undefined, + updatedAt, + }; + + if (options?.emitExitEvent !== false) { + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.exited", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + payload: { + reason: "Session stopped", + exitKind: "graceful", + }, + providerRefs: {}, + }); + } + + sessions.delete(context.session.threadId); + }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const context = sessions.get(threadId); + if (!context) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }), + ); + } + if (context.stopped || context.session.status === "closed") { + return Effect.fail( + new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId, + }), + ); + } + return Effect.succeed(context); + }; + + const startSession: ClaudeCodeAdapterShape["startSession"] = (input) => + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + + const startedAt = yield* nowIso; + const resumeState = readClaudeResumeState(input.resumeCursor); + const threadId = input.threadId; + + const promptQueue = yield* Queue.unbounded(); + const prompt = Stream.fromQueue(promptQueue).pipe( + Stream.filter((item) => item.type === "message"), + Stream.map((item) => item.message), + Stream.toAsyncIterable, + ); + + const pendingApprovals = new Map(); + const inFlightTools = new Map(); + + const contextRef = yield* Ref.make(undefined); + + const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) => + Effect.runPromise( + Effect.gen(function* () { + const context = yield* Ref.get(contextRef); + if (!context) { + return { + behavior: "deny", + message: "Claude session context is unavailable.", + } satisfies PermissionResult; + } + + const runtimeMode = input.runtimeMode ?? "full-access"; + if (runtimeMode === "full-access") { + return { + behavior: "allow", + updatedInput: toolInput, + } satisfies PermissionResult; + } + + const requestId = ApprovalRequestId.makeUnsafe(yield* Random.nextUUIDv4); + const requestType = classifyRequestType(toolName); + const detail = summarizeToolRequest(toolName, toolInput); + const requestMetadata = extractAgentMetadataFromRecord(toolInput, { + ...(callbackOptions.agentID ? { agentId: callbackOptions.agentID } : {}), + ...(callbackOptions.toolUseID ? { toolUseId: callbackOptions.toolUseID } : {}), + }); + const decisionDeferred = yield* Deferred.make(); + const pendingApproval: PendingApproval = { + requestType, + detail, + decision: decisionDeferred, + ...(callbackOptions.suggestions + ? { suggestions: callbackOptions.suggestions } + : {}), + }; + + const requestedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "request.opened", + eventId: requestedStamp.eventId, + provider: PROVIDER, + createdAt: requestedStamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState + ? { turnId: asCanonicalTurnId(context.turnState.turnId) } + : {}), + requestId: asRuntimeRequestId(requestId), + payload: { + requestType, + detail, + ...metadataFields(requestMetadata), + args: { + toolName, + input: toolInput, + ...(callbackOptions.toolUseID ? { toolUseId: callbackOptions.toolUseID } : {}), + }, + }, + providerRefs: { + ...(context.session.threadId + ? { providerThreadId: context.session.threadId } + : {}), + ...(context.turnState + ? { providerTurnId: String(context.turnState.turnId) } + : {}), + providerRequestId: requestId, + }, + raw: { + source: "claude.sdk.permission", + method: "canUseTool/request", + payload: { + toolName, + input: toolInput, + }, + }, + }); + + pendingApprovals.set(requestId, pendingApproval); + + const onAbort = () => { + if (!pendingApprovals.has(requestId)) { + return; + } + pendingApprovals.delete(requestId); + Effect.runFork(Deferred.succeed(decisionDeferred, "cancel")); + }; + + callbackOptions.signal.addEventListener("abort", onAbort, { + once: true, + }); + + const decision = yield* Deferred.await(decisionDeferred); + pendingApprovals.delete(requestId); + + const resolvedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "request.resolved", + eventId: resolvedStamp.eventId, + provider: PROVIDER, + createdAt: resolvedStamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState + ? { turnId: asCanonicalTurnId(context.turnState.turnId) } + : {}), + requestId: asRuntimeRequestId(requestId), + payload: { + requestType, + decision, + ...metadataFields(requestMetadata), + }, + providerRefs: { + ...(context.session.threadId + ? { providerThreadId: context.session.threadId } + : {}), + ...(context.turnState + ? { providerTurnId: String(context.turnState.turnId) } + : {}), + providerRequestId: requestId, + }, + raw: { + source: "claude.sdk.permission", + method: "canUseTool/decision", + payload: { + decision, + }, + }, + }); + + if (decision === "accept" || decision === "acceptForSession") { + return { + behavior: "allow", + updatedInput: toolInput, + ...(decision === "acceptForSession" && pendingApproval.suggestions + ? { updatedPermissions: [...pendingApproval.suggestions] } + : {}), + } satisfies PermissionResult; + } + + return { + behavior: "deny", + message: + decision === "cancel" + ? "User cancelled tool execution." + : "User declined tool execution.", + } satisfies PermissionResult; + }), + ); + + const providerOptions = input.providerOptions?.claudeCode; + const effort = input.modelOptions?.claudeCode?.effort; + const effectiveEffort = getEffectiveClaudeCodeEffort(effort); + const permissionMode = + toPermissionMode(providerOptions?.permissionMode) ?? + (input.runtimeMode === "full-access" ? "bypassPermissions" : undefined); + const queryEnv = { + ...process.env, + ...(providerOptions?.experimentalAgentTeams + ? { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: "1" } + : {}), + }; + + const queryOptions: ClaudeQueryOptions = { + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(input.model ? { model: input.model } : {}), + ...(providerOptions?.binaryPath + ? { pathToClaudeCodeExecutable: providerOptions.binaryPath } + : {}), + ...(effectiveEffort ? { effort: effectiveEffort } : {}), + ...(permissionMode ? { permissionMode } : {}), + ...(permissionMode === "bypassPermissions" + ? { allowDangerouslySkipPermissions: true } + : {}), + ...(providerOptions?.maxThinkingTokens !== undefined + ? { maxThinkingTokens: providerOptions.maxThinkingTokens } + : {}), + ...(providerOptions?.agentProgressSummaries !== undefined + ? { agentProgressSummaries: providerOptions.agentProgressSummaries } + : {}), + ...(providerOptions?.experimentalAgentTeams + ? { settings: { teammateMode: (providerOptions.teammateMode && providerOptions.teammateMode !== "auto" ? providerOptions.teammateMode : undefined) ?? CLAUDE_AGENT_TEAMS_TEAMMATE_MODE } } + : {}), + ...(resumeState?.resume ? { resume: resumeState.resume } : {}), + ...(resumeState?.resumeSessionAt ? { resumeSessionAt: resumeState.resumeSessionAt } : {}), + includePartialMessages: true, + canUseTool, + env: queryEnv, + ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), + }; + + const queryRuntime = yield* Effect.try({ + try: () => + createQuery({ + prompt, + options: queryOptions, + }), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: toMessage(cause, "Failed to start Claude runtime session."), + cause, + }), + }); + + const session: ProviderSession = { + threadId, + provider: PROVIDER, + status: "ready", + runtimeMode: input.runtimeMode, + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(input.model ? { model: input.model } : {}), + ...(threadId ? { threadId } : {}), + resumeCursor: { + ...(threadId ? { threadId } : {}), + ...(resumeState?.resume ? { resume: resumeState.resume } : {}), + ...(resumeState?.resumeSessionAt + ? { resumeSessionAt: resumeState.resumeSessionAt } + : {}), + turnCount: resumeState?.turnCount ?? 0, + }, + createdAt: startedAt, + updatedAt: startedAt, + }; + + const context: ClaudeSessionContext = { + session, + promptQueue, + query: queryRuntime, + startedAt, + resumeSessionId: resumeState?.resume, + pendingApprovals, + turns: [], + inFlightTools, + turnState: undefined, + lastAssistantUuid: resumeState?.resumeSessionAt, + lastThreadStartedId: undefined, + stopped: false, + }; + yield* Ref.set(contextRef, context); + sessions.set(threadId, context); + + const sessionStartedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.started", + eventId: sessionStartedStamp.eventId, + provider: PROVIDER, + createdAt: sessionStartedStamp.createdAt, + threadId, + payload: input.resumeCursor !== undefined ? { resume: input.resumeCursor } : {}, + providerRefs: {}, + }); + + const configuredStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.configured", + eventId: configuredStamp.eventId, + provider: PROVIDER, + createdAt: configuredStamp.createdAt, + threadId, + payload: { + config: { + ...(input.model ? { model: input.model } : {}), + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(effectiveEffort ? { effort: effectiveEffort } : {}), + ...(permissionMode ? { permissionMode } : {}), + ...(providerOptions?.maxThinkingTokens !== undefined + ? { maxThinkingTokens: providerOptions.maxThinkingTokens } + : {}), + ...(providerOptions?.experimentalAgentTeams ? { experimentalAgentTeams: true } : {}), + ...(providerOptions?.agentProgressSummaries !== undefined + ? { agentProgressSummaries: providerOptions.agentProgressSummaries } + : {}), + ...(providerOptions?.experimentalAgentTeams + ? { teammateMode: providerOptions.teammateMode ?? CLAUDE_AGENT_TEAMS_TEAMMATE_MODE } + : {}), + }, + }, + providerRefs: {}, + }); + + const readyStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.state.changed", + eventId: readyStamp.eventId, + provider: PROVIDER, + createdAt: readyStamp.createdAt, + threadId, + payload: { + state: "ready", + }, + providerRefs: {}, + }); + + Effect.runFork(runSdkStream(context)); + + return { + ...session, + }; + }); + + const sendTurn: ClaudeCodeAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const context = yield* requireSession(input.threadId); + + if (context.turnState) { + // Auto-close a stale synthetic turn (from teammate messages between user prompts) + // to prevent blocking the user's next turn. + yield* completeTurn(context, "completed"); + } + + if (input.model) { + yield* Effect.tryPromise({ + try: () => context.query.setModel(input.model), + catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause), + }); + } + + const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4); + const turnState: ClaudeTurnState = { + turnId, + assistantItemId: yield* Random.nextUUIDv4, + startedAt: yield* nowIso, + items: [], + messageCompleted: false, + emittedTextDelta: false, + fallbackAssistantText: "", + }; + + const updatedAt = yield* nowIso; + context.turnState = turnState; + context.session = { + ...context.session, + status: "running", + activeTurnId: turnId, + updatedAt, + }; + + const turnStartedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.started", + eventId: turnStartedStamp.eventId, + provider: PROVIDER, + createdAt: turnStartedStamp.createdAt, + threadId: context.session.threadId, + turnId, + payload: input.model ? { model: input.model } : {}, + providerRefs: { + providerTurnId: String(turnId), + }, + }); + + const message = buildUserMessage( + { + ...input, + input: input.input ?? "", + }, + context.resumeSessionId, + ); + + yield* Queue.offer(context.promptQueue, { + type: "message", + message, + }).pipe(Effect.mapError((cause) => toRequestError(input.threadId, "turn/start", cause))); + + return { + threadId: context.session.threadId, + turnId, + ...(context.session.resumeCursor !== undefined + ? { resumeCursor: context.session.resumeCursor } + : {}), + }; + }); + + const interruptTurn: ClaudeCodeAdapterShape["interruptTurn"] = (threadId, _turnId) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + yield* Effect.tryPromise({ + try: () => context.query.interrupt(), + catch: (cause) => toRequestError(threadId, "turn/interrupt", cause), + }); + }); + + const readThread: ClaudeCodeAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + return yield* snapshotThread(context); + }); + + const rollbackThread: ClaudeCodeAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + const nextLength = Math.max(0, context.turns.length - numTurns); + context.turns.splice(nextLength); + yield* updateResumeCursor(context); + return yield* snapshotThread(context); + }); + + const respondToRequest: ClaudeCodeAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + const pending = context.pendingApprovals.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "item/requestApproval/decision", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + + context.pendingApprovals.delete(requestId); + yield* Deferred.succeed(pending.decision, decision); + }); + + const respondToUserInput: ClaudeCodeAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + _answers, + ) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "item/tool/requestUserInput", + detail: `Claude Code does not yet support structured user-input responses for thread '${threadId}' and request '${requestId}'.`, + }), + ); + + const stopSession: ClaudeCodeAdapterShape["stopSession"] = (threadId) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + yield* stopSessionInternal(context, { + emitExitEvent: true, + }); + }); + + const listSessions: ClaudeCodeAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), ({ session }) => ({ ...session }))); + + const hasSession: ClaudeCodeAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const context = sessions.get(threadId); + return context !== undefined && !context.stopped; + }); + + const stopAll: ClaudeCodeAdapterShape["stopAll"] = () => + Effect.forEach( + sessions, + ([, context]) => + stopSessionInternal(context, { + emitExitEvent: true, + }), + { discard: true }, + ); + + yield* Effect.addFinalizer(() => + Effect.forEach( + sessions, + ([, context]) => + stopSessionInternal(context, { + emitExitEvent: false, + }), + { discard: true }, + ).pipe(Effect.tap(() => Queue.shutdown(runtimeEventQueue))), + ); + + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents: Stream.fromQueue(runtimeEventQueue), + } satisfies ClaudeCodeAdapterShape; + }); +} + +export const ClaudeCodeAdapterLive = Layer.effect(ClaudeCodeAdapter, makeClaudeCodeAdapter()); + +export function makeClaudeCodeAdapterLive(options?: ClaudeCodeAdapterLiveOptions) { + return Layer.effect(ClaudeCodeAdapter, makeClaudeCodeAdapter(options)); +} diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index c6f4a3c08..7b6da886e 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -4,6 +4,7 @@ import { assertFailure } from "@effect/vitest/utils"; import { Effect, Layer, Stream } from "effect"; +import { ClaudeCodeAdapter, ClaudeCodeAdapterShape } from "../Services/ClaudeCodeAdapter.ts"; import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; @@ -27,9 +28,32 @@ const fakeCodexAdapter: CodexAdapterShape = { streamEvents: Stream.empty, }; +const fakeClaudeAdapter: ClaudeCodeAdapterShape = { + provider: "claudeCode", + capabilities: { sessionModelSwitch: "restart-session" }, + startSession: vi.fn(), + sendTurn: vi.fn(), + interruptTurn: vi.fn(), + respondToRequest: vi.fn(), + respondToUserInput: vi.fn(), + stopSession: vi.fn(), + listSessions: vi.fn(), + hasSession: vi.fn(), + readThread: vi.fn(), + rollbackThread: vi.fn(), + stopAll: vi.fn(), + streamEvents: Stream.empty, +}; + const layer = it.layer( Layer.mergeAll( - Layer.provide(ProviderAdapterRegistryLive, Layer.succeed(CodexAdapter, fakeCodexAdapter)), + Layer.provide( + ProviderAdapterRegistryLive, + Layer.mergeAll( + Layer.succeed(CodexAdapter, fakeCodexAdapter), + Layer.succeed(ClaudeCodeAdapter, fakeClaudeAdapter), + ), + ), NodeServices.layer, ), ); @@ -39,10 +63,12 @@ layer("ProviderAdapterRegistryLive", (it) => { Effect.gen(function* () { const registry = yield* ProviderAdapterRegistry; const codex = yield* registry.getByProvider("codex"); + const claude = yield* registry.getByProvider("claudeCode"); assert.equal(codex, fakeCodexAdapter); + assert.equal(claude, fakeClaudeAdapter); const providers = yield* registry.listProviders(); - assert.deepEqual(providers, ["codex"]); + assert.deepEqual(providers, ["codex", "claudeCode"]); }), ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index 3062ed790..61fa2d18c 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -15,6 +15,7 @@ import { ProviderAdapterRegistry, type ProviderAdapterRegistryShape, } from "../Services/ProviderAdapterRegistry.ts"; +import { ClaudeCodeAdapter } from "../Services/ClaudeCodeAdapter.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; export interface ProviderAdapterRegistryLiveOptions { @@ -23,7 +24,10 @@ export interface ProviderAdapterRegistryLiveOptions { const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOptions) => Effect.gen(function* () { - const adapters = options?.adapters !== undefined ? options.adapters : [yield* CodexAdapter]; + const adapters = + options?.adapters !== undefined + ? options.adapters + : [yield* CodexAdapter, yield* ClaudeCodeAdapter]; const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts index 10bd12a7c..00c5d551a 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -5,11 +5,17 @@ import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; import { + CLAUDE_CLI_PATH, + checkClaudeProviderStatus, checkCodexProviderStatus, hasCustomModelProvider, + parseClaudeCliVersion, + parseClaudeAuthStatusFromOutput, parseAuthStatusFromOutput, + ProviderHealthLive, readCodexConfigModelProvider, } from "./ProviderHealth"; +import { ProviderHealth } from "../Services/ProviderHealth"; // ── Test helpers ──────────────────────────────────────────────────── @@ -42,6 +48,22 @@ function mockSpawnerLayer( ); } +function mockSpawnerCommandLayer( + handler: (input: { command: string; args: ReadonlyArray }) => { + stdout: string; + stderr: string; + code: number; + }, +) { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const cmd = command as unknown as { command: string; args: ReadonlyArray }; + return Effect.succeed(mockHandle(handler({ command: cmd.command, args: cmd.args }))); + }), + ); +} + function failingSpawnerLayer(description: string) { return Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, @@ -235,6 +257,107 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { ); }); + describe("checkClaudeProviderStatus", () => { + it.effect("returns ready when claude runtime is installed and authenticated", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus; + assert.strictEqual(status.provider, "claudeCode"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "authenticated"); + assert.strictEqual(status.version, "2.1.76"); + assert.strictEqual(status.capabilities?.agentTeams?.state, "available"); + }).pipe( + Effect.provide( + mockSpawnerCommandLayer(({ command, args }) => { + assert.strictEqual(command, CLAUDE_CLI_PATH); + const joined = args.join(" "); + if (joined === "--version") { + return { stdout: "2.1.76 (Claude Code)\n", stderr: "", code: 0 }; + } + if (joined === "auth status --json") { + return { + stdout: '{"authenticated":true,"email":"test@example.com"}\n', + stderr: "", + code: 0, + }; + } + throw new Error(`Unexpected Claude args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns unavailable when claude runtime is missing", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus; + assert.strictEqual(status.provider, "claudeCode"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, false); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual(status.message, "Claude Code runtime is not available."); + assert.strictEqual(status.capabilities?.agentTeams?.state, "misconfigured"); + }).pipe(Effect.provide(failingSpawnerLayer("spawn claude ENOENT"))), + ); + + it.effect("returns unauthenticated when claude auth probe reports login required", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus; + assert.strictEqual(status.provider, "claudeCode"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "unauthenticated"); + assert.strictEqual(status.version, "2.1.76"); + assert.strictEqual( + status.message, + "Claude Code is not authenticated. Run `claude auth login` and try again.", + ); + }).pipe( + Effect.provide( + mockSpawnerCommandLayer(({ command, args }) => { + assert.strictEqual(command, CLAUDE_CLI_PATH); + const joined = args.join(" "); + if (joined === "--version") { + return { stdout: "2.1.76 (Claude Code)\n", stderr: "", code: 0 }; + } + if (joined === "auth status --json") { + return { stdout: "", stderr: "Not logged in. Run claude auth login.", code: 1 }; + } + throw new Error(`Unexpected Claude args: ${joined}`); + }), + ), + ), + ); + + it.effect("marks agent teams unsupported on older Claude Code versions", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus; + assert.strictEqual(status.provider, "claudeCode"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.version, "2.1.20"); + assert.strictEqual(status.capabilities?.agentTeams?.state, "unsupported"); + assert.strictEqual( + status.capabilities?.agentTeams?.message, + "Agent Teams requires Claude Code v2.1.32 or newer.", + ); + }).pipe( + Effect.provide( + mockSpawnerCommandLayer(({ command, args }) => { + assert.strictEqual(command, CLAUDE_CLI_PATH); + const joined = args.join(" "); + if (joined === "--version") { + return { stdout: "2.1.20 (Claude Code)\n", stderr: "", code: 0 }; + } + if (joined === "auth status --json") { + return { stdout: '{"authenticated":true}\n', stderr: "", code: 0 }; + } + throw new Error(`Unexpected Claude args: ${joined}`); + }), + ), + ), + ); + }); + // ── Custom model provider: checkCodexProviderStatus integration ─── describe("checkCodexProviderStatus with custom model provider", () => { @@ -341,6 +464,78 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { }); }); + describe("parseClaudeAuthStatusFromOutput", () => { + it("exit code 0 with authenticated=true is ready", () => { + const parsed = parseClaudeAuthStatusFromOutput({ + stdout: '{"authenticated":true}\n', + stderr: "", + code: 0, + }); + assert.strictEqual(parsed.status, "ready"); + assert.strictEqual(parsed.authStatus, "authenticated"); + }); + + it("login required output is unauthenticated", () => { + const parsed = parseClaudeAuthStatusFromOutput({ + stdout: "", + stderr: "Not logged in. Run claude auth login.", + code: 1, + }); + assert.strictEqual(parsed.status, "error"); + assert.strictEqual(parsed.authStatus, "unauthenticated"); + }); + }); + + describe("parseClaudeCliVersion", () => { + it("extracts the Claude Code semver from version output", () => { + assert.strictEqual(parseClaudeCliVersion("2.1.76 (Claude Code)\n"), "2.1.76"); + }); + }); + + describe("ProviderHealthLive", () => { + it.effect("publishes codex and claude provider statuses", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const providerHealth = yield* ProviderHealth; + const statuses = yield* providerHealth.getStatuses; + + assert.deepStrictEqual( + statuses.map((status) => status.provider), + ["codex", "claudeCode"], + ); + }).pipe( + Effect.provide( + ProviderHealthLive.pipe( + Layer.provide( + mockSpawnerCommandLayer(({ command, args }) => { + const joined = args.join(" "); + if (command === "codex") { + if (joined === "--version") { + return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + } + if (joined === "login status") { + return { stdout: "Logged in\n", stderr: "", code: 0 }; + } + } + + if (command === CLAUDE_CLI_PATH) { + if (joined === "--version") { + return { stdout: "2.1.76 (Claude Code)\n", stderr: "", code: 0 }; + } + if (joined === "auth status --json") { + return { stdout: '{"authenticated":true}\n', stderr: "", code: 0 }; + } + } + + throw new Error(`Unexpected provider health command: ${command} ${joined}`); + }), + ), + ), + ), + ), + ); + }); + // ── readCodexConfigModelProvider tests ───────────────────────────── describe("readCodexConfigModelProvider", () => { diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 1fed0597a..6c0e08e81 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -9,12 +9,14 @@ * @module ProviderHealthLive */ import * as OS from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; import type { ServerProviderAuthStatus, ServerProviderStatus, ServerProviderStatusState, } from "@t3tools/contracts"; -import { Array, Effect, Fiber, FileSystem, Layer, Option, Path, Result, Stream } from "effect"; +import { Array, Effect, FileSystem, Layer, Option, Path, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { @@ -26,6 +28,13 @@ import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHe const DEFAULT_TIMEOUT_MS = 4_000; const CODEX_PROVIDER = "codex" as const; +const CLAUDE_PROVIDER = "claudeCode" as const; +const MIN_CLAUDE_AGENT_TEAMS_VERSION = "2.1.32"; +const require = createRequire(import.meta.url); +export const CLAUDE_CLI_PATH = path.join( + path.dirname(require.resolve("@anthropic-ai/claude-agent-sdk")), + "cli.js", +); // ── Pure helpers ──────────────────────────────────────────────────── @@ -41,12 +50,12 @@ function nonEmptyTrimmed(value: string | undefined): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } -function isCommandMissingCause(error: unknown): boolean { +function isCommandMissingCause(command: string, error: unknown): boolean { if (!(error instanceof Error)) return false; const lower = error.message.toLowerCase(); return ( - lower.includes("command not found: codex") || - lower.includes("spawn codex enoent") || + lower.includes(`command not found: ${command.toLowerCase()}`) || + lower.includes(`spawn ${command.toLowerCase()} enoent`) || lower.includes("enoent") || lower.includes("notfound") ); @@ -88,6 +97,69 @@ function extractAuthBoolean(value: unknown): boolean | undefined { return undefined; } +function parseSemverParts(value: string): [number, number, number] | null { + const match = /(\d+)\.(\d+)\.(\d+)/.exec(value); + if (!match) { + return null; + } + return [ + Number.parseInt(match[1] ?? "", 10), + Number.parseInt(match[2] ?? "", 10), + Number.parseInt(match[3] ?? "", 10), + ]; +} + +export function parseClaudeCliVersion(output: string): string | undefined { + const match = /(\d+\.\d+\.\d+)/.exec(output); + return match?.[1]; +} + +function isClaudeCliVersionAtLeast(version: string, minimumVersion: string): boolean { + const current = parseSemverParts(version); + const minimum = parseSemverParts(minimumVersion); + if (!current || !minimum) { + return false; + } + for (let index = 0; index < 3; index += 1) { + const currentPart = current[index] ?? 0; + const minimumPart = minimum[index] ?? 0; + if (currentPart > minimumPart) { + return true; + } + if (currentPart < minimumPart) { + return false; + } + } + return true; +} + +function buildClaudeAgentTeamsCapability(version: string | undefined) { + if (!version) { + return { + state: "misconfigured" as const, + minimumVersion: MIN_CLAUDE_AGENT_TEAMS_VERSION, + requiresExperimentalFlag: true, + message: "Could not determine Claude Code version for Agent Teams support.", + }; + } + + if (!isClaudeCliVersionAtLeast(version, MIN_CLAUDE_AGENT_TEAMS_VERSION)) { + return { + state: "unsupported" as const, + minimumVersion: MIN_CLAUDE_AGENT_TEAMS_VERSION, + requiresExperimentalFlag: true, + message: `Agent Teams requires Claude Code v${MIN_CLAUDE_AGENT_TEAMS_VERSION} or newer.`, + }; + } + + return { + state: "available" as const, + minimumVersion: MIN_CLAUDE_AGENT_TEAMS_VERSION, + requiresExperimentalFlag: true, + message: "Agent Teams is available and will be enabled per session when requested.", + }; +} + export function parseAuthStatusFromOutput(result: CommandResult): { readonly status: ServerProviderStatusState; readonly authStatus: ServerProviderAuthStatus; @@ -243,14 +315,18 @@ const collectStreamAsString = (stream: Stream.Stream): Effect. (acc, chunk) => acc + new TextDecoder().decode(chunk), ); -const runCodexCommand = (args: ReadonlyArray) => +const runCodexCommand = (args: ReadonlyArray) => runCommand("codex", args); + +const runClaudeCommand = (args: ReadonlyArray) => runCommand(CLAUDE_CLI_PATH, args); + +const runCommand = (command: string, args: ReadonlyArray) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const command = ChildProcess.make("codex", [...args], { + const childProcess = ChildProcess.make(command, [...args], { shell: process.platform === "win32", }); - const child = yield* spawner.spawn(command); + const child = yield* spawner.spawn(childProcess); const [stdout, stderr, exitCode] = yield* Effect.all( [ @@ -287,7 +363,7 @@ export const checkCodexProviderStatus: Effect.Effect< available: false, authStatus: "unknown" as const, checkedAt, - message: isCommandMissingCause(error) + message: isCommandMissingCause("codex", error) ? "Codex CLI (`codex`) is not installed or not on PATH." : `Failed to execute Codex CLI health check: ${error instanceof Error ? error.message : String(error)}.`, }; @@ -390,18 +466,216 @@ export const checkCodexProviderStatus: Effect.Effect< } satisfies ServerProviderStatus; }); +export function parseClaudeAuthStatusFromOutput(result: CommandResult): { + readonly status: ServerProviderStatusState; + readonly authStatus: ServerProviderAuthStatus; + readonly message?: string; +} { + const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); + + if ( + lowerOutput.includes("unknown command") || + lowerOutput.includes("unrecognized command") || + lowerOutput.includes("unexpected argument") + ) { + return { + status: "warning", + authStatus: "unknown", + message: "Claude Code authentication status command is unavailable in this version.", + }; + } + + if ( + lowerOutput.includes("not logged in") || + lowerOutput.includes("login required") || + lowerOutput.includes("authentication required") || + lowerOutput.includes("sign in") || + lowerOutput.includes("run `claude auth login`") || + lowerOutput.includes("run claude auth login") + ) { + return { + status: "error", + authStatus: "unauthenticated", + message: "Claude Code is not authenticated. Run `claude auth login` and try again.", + }; + } + + const parsedAuth = (() => { + const trimmed = result.stdout.trim(); + if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) { + return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; + } + try { + return { + attemptedJsonParse: true as const, + auth: extractAuthBoolean(JSON.parse(trimmed)), + }; + } catch { + return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; + } + })(); + + if (parsedAuth.auth === true) { + return { status: "ready", authStatus: "authenticated" }; + } + if (parsedAuth.auth === false) { + return { + status: "error", + authStatus: "unauthenticated", + message: "Claude Code is not authenticated. Run `claude auth login` and try again.", + }; + } + if (parsedAuth.attemptedJsonParse) { + return { + status: "warning", + authStatus: "unknown", + message: + "Could not verify Claude Code authentication status from JSON output (missing auth marker).", + }; + } + if (result.code === 0) { + return { status: "ready", authStatus: "authenticated" }; + } + + const detail = detailFromResult(result); + return { + status: "warning", + authStatus: "unknown", + message: detail + ? `Could not verify Claude Code authentication status. ${detail}` + : "Could not verify Claude Code authentication status.", + }; +} + +export const checkClaudeProviderStatus: Effect.Effect< + ServerProviderStatus, + never, + ChildProcessSpawner.ChildProcessSpawner +> = Effect.gen(function* () { + const checkedAt = new Date().toISOString(); + + const versionProbe = yield* runClaudeCommand(["--version"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionProbe)) { + const error = versionProbe.failure; + return { + provider: CLAUDE_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: isCommandMissingCause("claude", error) + ? "Claude Code runtime is not available." + : `Failed to execute Claude Code health check: ${error instanceof Error ? error.message : String(error)}.`, + capabilities: { + agentTeams: buildClaudeAgentTeamsCapability(undefined), + }, + }; + } + + if (Option.isNone(versionProbe.success)) { + return { + provider: CLAUDE_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: + "Claude Code runtime is installed but failed to run. Timed out while running command.", + capabilities: { + agentTeams: buildClaudeAgentTeamsCapability(undefined), + }, + }; + } + + const version = versionProbe.success.value; + const parsedVersion = parseClaudeCliVersion(`${version.stdout}\n${version.stderr}`); + if (version.code !== 0) { + const detail = detailFromResult(version); + return { + provider: CLAUDE_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + ...(parsedVersion ? { version: parsedVersion } : {}), + message: detail + ? `Claude Code runtime is installed but failed to run. ${detail}` + : "Claude Code runtime is installed but failed to run.", + capabilities: { + agentTeams: buildClaudeAgentTeamsCapability(parsedVersion), + }, + }; + } + + const authProbe = yield* runClaudeCommand(["auth", "status", "--json"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(authProbe)) { + const error = authProbe.failure; + return { + provider: CLAUDE_PROVIDER, + status: "warning" as const, + available: true, + authStatus: "unknown" as const, + checkedAt, + message: + error instanceof Error + ? `Could not verify Claude Code authentication status: ${error.message}.` + : "Could not verify Claude Code authentication status.", + }; + } + + if (Option.isNone(authProbe.success)) { + return { + provider: CLAUDE_PROVIDER, + status: "warning" as const, + available: true, + authStatus: "unknown" as const, + checkedAt, + message: + "Could not verify Claude Code authentication status. Timed out while running command.", + }; + } + + const parsed = parseClaudeAuthStatusFromOutput(authProbe.success.value); + return { + provider: CLAUDE_PROVIDER, + status: parsed.status, + available: true, + authStatus: parsed.authStatus, + checkedAt, + ...(parsedVersion ? { version: parsedVersion } : {}), + ...(parsed.message ? { message: parsed.message } : {}), + capabilities: { + agentTeams: buildClaudeAgentTeamsCapability(parsedVersion), + }, + } satisfies ServerProviderStatus; +}); + // ── Layer ─────────────────────────────────────────────────────────── export const ProviderHealthLive = Layer.effect( ProviderHealth, Effect.gen(function* () { - const codexStatusFiber = yield* checkCodexProviderStatus.pipe( - Effect.map(Array.of), - Effect.forkScoped, + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const healthRuntime = Layer.mergeAll( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + Layer.succeed(FileSystem.FileSystem, fileSystem), + Layer.succeed(Path.Path, path), ); return { - getStatuses: Fiber.join(codexStatusFiber), + getStatuses: Effect.all([checkCodexProviderStatus, checkClaudeProviderStatus], { + concurrency: "unbounded", + }).pipe(Effect.provide(healthRuntime)), } satisfies ProviderHealthShape; }), ); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index d5cf4424b..693895e32 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -52,7 +52,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: "codex"; + readonly provider: "codex" | "claudeCode"; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; @@ -217,12 +217,15 @@ const sleep = (ms: number) => function makeProviderServiceLayer() { const codex = makeFakeCodexAdapter(); + const claude = makeFakeCodexAdapter("claudeCode"); const registry: typeof ProviderAdapterRegistry.Service = { getByProvider: (provider) => provider === "codex" ? Effect.succeed(codex.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["codex"]), + : provider === "claudeCode" + ? Effect.succeed(claude.adapter) + : Effect.fail(new ProviderUnsupportedError({ provider })), + listProviders: () => Effect.succeed(["codex", "claudeCode"]), }; const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); @@ -247,6 +250,7 @@ function makeProviderServiceLayer() { return { codex, + claude, layer, }; } @@ -493,6 +497,29 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("routes explicit claudeCode provider session starts to the claude adapter", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + + const session = yield* provider.startSession(asThreadId("thread-claude"), { + provider: "claudeCode", + threadId: asThreadId("thread-claude"), + cwd: "/tmp/project-claude", + runtimeMode: "full-access", + }); + + assert.equal(session.provider, "claudeCode"); + assert.equal(routing.claude.startSession.mock.calls.length, 1); + const startInput = routing.claude.startSession.mock.calls[0]?.[0]; + assert.equal(typeof startInput === "object" && startInput !== null, true); + if (startInput && typeof startInput === "object") { + const startPayload = startInput as { provider?: string; cwd?: string }; + assert.equal(startPayload.provider, "claudeCode"); + assert.equal(startPayload.cwd, "/tmp/project-claude"); + } + }), + ); + it.effect("recovers stale persisted sessions for rollback by resuming thread identity", () => Effect.gen(function* () { const provider = yield* ProviderService; @@ -573,10 +600,63 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("recovers stale claudeCode sessions for sendTurn using persisted cwd", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + + const initial = yield* provider.startSession(asThreadId("thread-claude-send-turn"), { + provider: "claudeCode", + threadId: asThreadId("thread-claude-send-turn"), + cwd: "/tmp/project-claude-send-turn", + modelOptions: { + claudeCode: { + effort: "max", + }, + }, + runtimeMode: "full-access", + }); + + yield* routing.claude.stopAll(); + routing.claude.startSession.mockClear(); + routing.claude.sendTurn.mockClear(); + + yield* provider.sendTurn({ + threadId: initial.threadId, + input: "resume with claude", + attachments: [], + }); + + assert.equal(routing.claude.startSession.mock.calls.length, 1); + const resumedStartInput = routing.claude.startSession.mock.calls[0]?.[0]; + assert.equal(typeof resumedStartInput === "object" && resumedStartInput !== null, true); + if (resumedStartInput && typeof resumedStartInput === "object") { + const startPayload = resumedStartInput as { + provider?: string; + cwd?: string; + modelOptions?: unknown; + resumeCursor?: unknown; + threadId?: string; + }; + assert.equal(startPayload.provider, "claudeCode"); + assert.equal(startPayload.cwd, "/tmp/project-claude-send-turn"); + assert.deepEqual(startPayload.modelOptions, { + claudeCode: { + effort: "max", + }, + }); + assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); + assert.equal(startPayload.threadId, initial.threadId); + } + assert.equal(routing.claude.sendTurn.mock.calls.length, 1); + }), + ); + it.effect("lists no sessions after adapter runtime clears", () => Effect.gen(function* () { const provider = yield* ProviderService; + yield* routing.claude.stopAll(); + yield* provider.startSession(asThreadId("thread-1"), { provider: "codex", threadId: asThreadId("thread-1"), diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 8e3bc7204..0a250f965 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -88,17 +88,29 @@ function toRuntimeStatus(session: ProviderSession): "starting" | "running" | "st function toRuntimePayloadFromSession( session: ProviderSession, - extra?: { readonly providerOptions?: unknown }, + extra?: { readonly modelOptions?: unknown; readonly providerOptions?: unknown }, ): Record { return { cwd: session.cwd ?? null, model: session.model ?? null, activeTurnId: session.activeTurnId ?? null, lastError: session.lastError ?? null, + ...(extra?.modelOptions !== undefined ? { modelOptions: extra.modelOptions } : {}), ...(extra?.providerOptions !== undefined ? { providerOptions: extra.providerOptions } : {}), }; } +function readPersistedModelOptions( + runtimePayload: ProviderRuntimeBinding["runtimePayload"], +): Record | undefined { + if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { + return undefined; + } + const raw = "modelOptions" in runtimePayload ? runtimePayload.modelOptions : undefined; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; + return raw as Record; +} + function readPersistedProviderOptions( runtimePayload: ProviderRuntimeBinding["runtimePayload"], ): Record | undefined { @@ -150,7 +162,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const upsertSessionBinding = ( session: ProviderSession, threadId: ThreadId, - extra?: { readonly providerOptions?: unknown }, + extra?: { readonly modelOptions?: unknown; readonly providerOptions?: unknown }, ) => directory.upsert({ threadId, @@ -213,12 +225,14 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => } const persistedCwd = readPersistedCwd(input.binding.runtimePayload); + const persistedModelOptions = readPersistedModelOptions(input.binding.runtimePayload); const persistedProviderOptions = readPersistedProviderOptions(input.binding.runtimePayload); const resumed = yield* adapter.startSession({ threadId: input.binding.threadId, provider: input.binding.provider, ...(persistedCwd ? { cwd: persistedCwd } : {}), + ...(persistedModelOptions ? { modelOptions: persistedModelOptions } : {}), ...(persistedProviderOptions ? { providerOptions: persistedProviderOptions } : {}), ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), runtimeMode: input.binding.runtimeMode ?? "full-access", @@ -292,6 +306,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => } yield* upsertSessionBinding(session, threadId, { + modelOptions: input.modelOptions, providerOptions: input.providerOptions, }); yield* analytics.record("provider.session.started", { diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 1882c1cc0..d524d8f4f 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -204,4 +204,26 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL fs.rmSync(tempDir, { recursive: true, force: true }); })); + + it("accepts claude provider bindings", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const threadId = ThreadId.makeUnsafe("thread-claude"); + + yield* directory.upsert({ + provider: "claudeCode", + threadId, + }); + + const provider = yield* directory.getProvider(threadId); + assert.equal(provider, "claudeCode"); + const resolvedBinding = yield* directory.getBinding(threadId); + assertSome(resolvedBinding, { + threadId, + provider: "claudeCode", + }); + if (Option.isSome(resolvedBinding)) { + assert.equal(resolvedBinding.value.threadId, threadId); + } + })); }); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 38e097e1c..71b6d3c42 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -22,7 +22,7 @@ function decodeProviderKind( providerName: string, operation: string, ): Effect.Effect { - if (providerName === "codex") { + if (providerName === "codex" || providerName === "claudeCode") { return Effect.succeed(providerName); } return Effect.fail( diff --git a/apps/server/src/provider/Services/ClaudeCodeAdapter.ts b/apps/server/src/provider/Services/ClaudeCodeAdapter.ts new file mode 100644 index 000000000..6ef687606 --- /dev/null +++ b/apps/server/src/provider/Services/ClaudeCodeAdapter.ts @@ -0,0 +1,31 @@ +/** + * ClaudeCodeAdapter - Claude Code implementation of the generic provider adapter contract. + * + * This service owns Claude runtime/session semantics and emits canonical + * provider runtime events. It does not perform cross-provider routing, shared + * event fan-out, or checkpoint orchestration. + * + * Uses Effect `ServiceMap.Service` for dependency injection and returns the + * shared provider-adapter error channel with `provider: "claudeCode"` context. + * + * @module ClaudeCodeAdapter + */ +import { ServiceMap } from "effect"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * ClaudeCodeAdapterShape - Service API for the Claude Code provider adapter. + */ +export interface ClaudeCodeAdapterShape extends ProviderAdapterShape { + readonly provider: "claudeCode"; +} + +/** + * ClaudeCodeAdapter - Service tag for Claude Code provider adapter operations. + */ +export class ClaudeCodeAdapter extends ServiceMap.Service< + ClaudeCodeAdapter, + ClaudeCodeAdapterShape +>()("t3/provider/Services/ClaudeCodeAdapter") {} diff --git a/apps/server/src/provider/Services/ProviderHealth.ts b/apps/server/src/provider/Services/ProviderHealth.ts index 318d7e18d..ec3b2d318 100644 --- a/apps/server/src/provider/Services/ProviderHealth.ts +++ b/apps/server/src/provider/Services/ProviderHealth.ts @@ -1,8 +1,8 @@ /** * ProviderHealth - Provider readiness snapshot service. * - * Owns startup-time provider health checks (install/auth reachability) and - * exposes the cached results to transport layers. + * Owns provider health checks (install/auth reachability) and exposes the + * latest results to transport layers. * * @module ProviderHealth */ @@ -12,7 +12,7 @@ import type { Effect } from "effect"; export interface ProviderHealthShape { /** - * Read provider health statuses computed at server startup. + * Read the latest provider health statuses. */ readonly getStatuses: Effect.Effect>; } diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index ff9b10d96..dea77b9fc 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -19,6 +19,7 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus"; import { ProviderUnsupportedError } from "./provider/Errors"; +import { makeClaudeCodeAdapterLive } from "./provider/Layers/ClaudeCodeAdapter"; import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; @@ -58,8 +59,12 @@ export function makeServerProviderLayer(): Layer.Layer< const codexAdapterLayer = makeCodexAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); + const claudeAdapterLayer = makeClaudeCodeAdapterLive( + nativeEventLogger ? { nativeEventLogger } : undefined, + ); const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), + Layer.provide(claudeAdapterLayer), Layer.provideMerge(providerSessionDirectoryLayer), ); return makeProviderServiceLive( diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 326bceaac..c566a7037 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_TIMESTAMP_FORMAT, + DEFAULT_APP_SETTINGS, getAppModelOptions, normalizeCustomModelSlugs, resolveAppModelSelection, @@ -45,6 +46,17 @@ describe("getAppModelOptions", () => { isCustom: true, }); }); + + it("supports Claude built-ins and saved custom models", () => { + const options = getAppModelOptions("claudeCode", ["claude-internal-preview"]); + + expect(options.map((option) => option.slug)).toEqual([ + "claude-opus-4-6", + "claude-sonnet-4-6", + "claude-haiku-4-5", + "claude-internal-preview", + ]); + }); }); describe("resolveAppModelSelection", () => { @@ -57,6 +69,12 @@ describe("resolveAppModelSelection", () => { it("falls back to the provider default when no model is selected", () => { expect(resolveAppModelSelection("codex", [], "")).toBe("gpt-5.4"); }); + + it("preserves Claude custom model slugs instead of falling back", () => { + expect( + resolveAppModelSelection("claudeCode", ["claude-sonnet-internal"], "claude-sonnet-internal"), + ).toBe("claude-sonnet-internal"); + }); }); describe("timestamp format defaults", () => { @@ -64,3 +82,10 @@ describe("timestamp format defaults", () => { expect(DEFAULT_TIMESTAMP_FORMAT).toBe("locale"); }); }); + +describe("Claude teams defaults", () => { + it("defaults Claude agent teams settings to disabled", () => { + expect(DEFAULT_APP_SETTINGS.claudeExperimentalAgentTeams).toBe(false); + expect(DEFAULT_APP_SETTINGS.claudeAgentProgressSummaries).toBe(false); + }); +}); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 18e76d2f9..293c3b1a6 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -12,6 +12,7 @@ export type TimestampFormat = (typeof TIMESTAMP_FORMAT_OPTIONS)[number]; export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { codex: new Set(getModelOptions("codex").map((option) => option.slug)), + claudeCode: new Set(getModelOptions("claudeCode").map((option) => option.slug)), }; const AppSettingsSchema = Schema.Struct({ @@ -34,6 +35,18 @@ const AppSettingsSchema = Schema.Struct({ customCodexModels: Schema.Array(Schema.String).pipe( Schema.withConstructorDefault(() => Option.some([])), ), + customClaudeModels: Schema.Array(Schema.String).pipe( + Schema.withConstructorDefault(() => Option.some([])), + ), + claudeExperimentalAgentTeams: Schema.Boolean.pipe( + Schema.withConstructorDefault(() => Option.some(false)), + ), + claudeAgentProgressSummaries: Schema.Boolean.pipe( + Schema.withConstructorDefault(() => Option.some(false)), + ), + claudeTeammateMode: Schema.Literals(["in-process", "tmux", "auto"]).pipe( + Schema.withConstructorDefault(() => Option.some("auto" as const)), + ), }); export type AppSettings = typeof AppSettingsSchema.Type; export interface AppModelOption { @@ -42,7 +55,7 @@ export interface AppModelOption { isCustom: boolean; } -const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); +export const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); export function normalizeCustomModelSlugs( models: Iterable, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 59e290431..2bd3af151 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -118,8 +118,26 @@ export function cloneComposerImageForRetry( export function getCustomModelOptionsByProvider(settings: { customCodexModels: readonly string[]; + customClaudeModels: readonly string[]; }): Record> { return { codex: getAppModelOptions("codex", settings.customCodexModels), + claudeCode: getAppModelOptions("claudeCode", settings.customClaudeModels), }; } + +export function getCustomModelSlugsForProvider( + settings: { + customCodexModels: readonly string[]; + customClaudeModels: readonly string[]; + }, + provider: ProviderKind, +): readonly string[] { + switch (provider) { + case "claudeCode": + return settings.customClaudeModels; + case "codex": + default: + return settings.customCodexModels; + } +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 52637695e..460cae205 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,9 +1,9 @@ import { type ApprovalRequestId, DEFAULT_MODEL_BY_PROVIDER, + type ClaudeCodeEffort, type EditorId, type KeybindingCommand, - type CodexReasoningEffort, type MessageId, type ProjectId, type ProjectEntry, @@ -15,6 +15,7 @@ import { type ProviderApprovalDecision, type ServerProviderStatus, type ProviderKind, + type ProviderReasoningEffort, type ThreadId, type TurnId, OrchestrationThreadActivity, @@ -22,10 +23,12 @@ import { ProviderInteractionMode, } from "@t3tools/contracts"; import { + applyClaudePromptEffortPrefix, getDefaultModel, getDefaultReasoningEffort, getReasoningEffortOptions, normalizeModelSlug, + resolveReasoningEffortForProvider, resolveModelSlugForProvider, } from "@t3tools/shared/model"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; @@ -47,6 +50,7 @@ import { replaceTextRange, } from "../composer-logic"; import { + deriveAgentTeamsState, derivePendingApprovals, derivePendingUserInputs, derivePhase, @@ -131,13 +135,14 @@ import { shouldUseCompactComposerFooter } from "./composerFooterLayout"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; +import { AgentTeamsPanel } from "./chat/AgentTeamsPanel"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import { ChatHeader } from "./chat/ChatHeader"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker"; import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; -import { CodexTraitsPicker } from "./chat/CodexTraitsPicker"; +import { ProviderTraitsPicker } from "./chat/CodexTraitsPicker"; import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu"; import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; @@ -149,6 +154,7 @@ import { buildTemporaryWorktreeBranchName, cloneComposerImageForRetry, collectUserMessageBlobPreviewUrls, + getCustomModelSlugsForProvider, getCustomModelOptionsByProvider, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, @@ -170,6 +176,17 @@ const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; + +function formatOutgoingPrompt(params: { + provider: ProviderKind; + effort: ProviderReasoningEffort | null; + text: string; +}): string { + if (params.provider !== "claudeCode") { + return params.text; + } + return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeCodeEffort | null); +} const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; @@ -206,6 +223,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const { resolvedTheme } = useTheme(); const queryClient = useQueryClient(); const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient })); + const serverConfigQuery = useQuery(serverConfigQueryOptions()); const composerDraft = useComposerThreadDraft(threadId); const prompt = composerDraft.prompt; const composerImages = composerDraft.images; @@ -230,6 +248,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); + const copyComposerDraftSettings = useComposerDraftStore((store) => store.copyThreadSettings); const getDraftThreadByProjectId = useComposerDraftStore( (store) => store.getDraftThreadByProjectId, ); @@ -437,6 +456,7 @@ export default function ChatView({ threadId }: ChatViewProps) { interactionMode: DEFAULT_INTERACTION_MODE, ...input, }); + copyComposerDraftSettings(threadId, nextThreadId); await navigate({ to: "/$threadId", params: { threadId: nextThreadId }, @@ -445,6 +465,7 @@ export default function ChatView({ threadId }: ChatViewProps) { [ activeProject, clearProjectDraftThreadId, + copyComposerDraftSettings, getDraftThread, getDraftThreadByProjectId, isServerThread, @@ -500,7 +521,10 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedProvider, activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider), ); - const customModelsForSelectedProvider = settings.customCodexModels; + const customModelsForSelectedProvider = getCustomModelSlugsForProvider( + settings, + selectedProvider, + ); const selectedModel = useMemo(() => { const draftModel = composerDraft.model; if (!draftModel) { @@ -514,20 +538,69 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [baseThreadModel, composerDraft.model, customModelsForSelectedProvider, selectedProvider]); const reasoningOptions = getReasoningEffortOptions(selectedProvider); const supportsReasoningEffort = reasoningOptions.length > 0; - const selectedEffort = composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider); + const selectedCodexEffort = + selectedProvider === "codex" + ? (resolveReasoningEffortForProvider("codex", composerDraft.effort) ?? + getDefaultReasoningEffort("codex")) + : null; + const selectedClaudeEffort = + selectedProvider === "claudeCode" + ? (resolveReasoningEffortForProvider("claudeCode", composerDraft.effort) ?? + getDefaultReasoningEffort("claudeCode")) + : null; + const selectedEffort = selectedCodexEffort ?? selectedClaudeEffort; const selectedCodexFastModeEnabled = selectedProvider === "codex" ? composerDraft.codexFastMode : false; + const isClaudeUltrathink = selectedClaudeEffort === "ultrathink"; + const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES; + const activeProvider = activeThread?.session?.provider ?? "codex"; + const activeProviderStatus = useMemo( + () => providerStatuses.find((status) => status.provider === activeProvider) ?? null, + [activeProvider, providerStatuses], + ); + const claudeProviderStatus = useMemo( + () => providerStatuses.find((status) => status.provider === "claudeCode") ?? null, + [providerStatuses], + ); + const claudeAgentTeamsCapability = claudeProviderStatus?.capabilities?.agentTeams; + const claudeAgentTeamsEnabledForDispatch = + settings.claudeExperimentalAgentTeams && + (claudeProviderStatus === null || claudeAgentTeamsCapability?.state === "available"); const selectedModelOptionsForDispatch = useMemo(() => { - if (selectedProvider !== "codex") { - return undefined; + if (selectedProvider === "codex") { + const codexOptions = { + ...(supportsReasoningEffort && selectedCodexEffort + ? { reasoningEffort: selectedCodexEffort } + : {}), + ...(selectedCodexFastModeEnabled ? { fastMode: true } : {}), + }; + return Object.keys(codexOptions).length > 0 ? { codex: codexOptions } : undefined; } - const codexOptions = { - ...(supportsReasoningEffort && selectedEffort ? { reasoningEffort: selectedEffort } : {}), - ...(selectedCodexFastModeEnabled ? { fastMode: true } : {}), - }; - return Object.keys(codexOptions).length > 0 ? { codex: codexOptions } : undefined; - }, [selectedCodexFastModeEnabled, selectedEffort, selectedProvider, supportsReasoningEffort]); + if (selectedProvider === "claudeCode" && selectedClaudeEffort) { + return { + claudeCode: { + effort: selectedClaudeEffort, + }, + }; + } + return undefined; + }, [ + selectedClaudeEffort, + selectedCodexEffort, + selectedCodexFastModeEnabled, + selectedProvider, + supportsReasoningEffort, + ]); const providerOptionsForDispatch = useMemo(() => { + if (selectedProvider === "claudeCode") { + return { + claudeCode: { + experimentalAgentTeams: claudeAgentTeamsEnabledForDispatch, + agentProgressSummaries: settings.claudeAgentProgressSummaries, + teammateMode: settings.claudeTeammateMode, + }, + }; + } if (!settings.codexBinaryPath && !settings.codexHomePath) { return undefined; } @@ -537,7 +610,14 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(settings.codexHomePath ? { homePath: settings.codexHomePath } : {}), }, }; - }, [settings.codexBinaryPath, settings.codexHomePath]); + }, [ + claudeAgentTeamsEnabledForDispatch, + selectedProvider, + settings.claudeAgentProgressSummaries, + settings.claudeTeammateMode, + settings.codexBinaryPath, + settings.codexHomePath, + ]); const selectedModelForPicker = selectedModel; const modelOptionsByProvider = useMemo( () => getCustomModelOptionsByProvider(settings), @@ -581,6 +661,14 @@ export default function ChatView({ threadId }: ChatViewProps) { () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined), [activeLatestTurn?.turnId, threadActivities], ); + const agentTeamsState = useMemo( + () => deriveAgentTeamsState(threadActivities), + [threadActivities], + ); + const showAgentTeamsPanel = + (selectedProvider === "claudeCode" || activeThread?.session?.provider === "claudeCode") && + agentTeamsState.hasTeamActivity && + agentTeamsState.activeRunId !== null; const latestTurnHasToolActivity = useMemo( () => hasToolActivityForTurn(threadActivities, activeLatestTurn?.turnId), [activeLatestTurn?.turnId, threadActivities], @@ -811,8 +899,13 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [serverMessages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]); const timelineEntries = useMemo( () => - deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), - [activeThread?.proposedPlans, timelineMessages, workLogEntries], + deriveTimelineEntries( + timelineMessages, + activeThread?.proposedPlans ?? [], + workLogEntries, + threadActivities, + ), + [activeThread?.proposedPlans, threadActivities, timelineMessages, workLogEntries], ); const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); @@ -913,7 +1006,6 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd)); - const serverConfigQuery = useQuery(serverConfigQueryOptions()); const workspaceEntriesQuery = useQuery( projectSearchEntriesQueryOptions({ cwd: gitCwd, @@ -1003,12 +1095,6 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; - const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES; - const activeProvider = activeThread?.session?.provider ?? "codex"; - const activeProviderStatus = useMemo( - () => providerStatuses.find((status) => status.provider === activeProvider) ?? null, - [activeProvider, providerStatuses], - ); const activeProjectCwd = activeProject?.cwd ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const threadTerminalRuntimeEnv = useMemo(() => { @@ -2259,6 +2345,11 @@ export default function ChatView({ threadId }: ChatViewProps) { const composerImagesSnapshot = [...composerImages]; const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); + const outgoingMessageText = formatOutgoingPrompt({ + provider: selectedProvider, + effort: selectedEffort, + text: trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT, + }); const turnAttachmentsPromise = Promise.all( composerImagesSnapshot.map(async (image) => ({ type: "image" as const, @@ -2281,7 +2372,7 @@ export default function ChatView({ threadId }: ChatViewProps) { { id: messageIdForSend, role: "user", - text: trimmed, + text: outgoingMessageText, ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), createdAt: messageCreatedAt, streaming: false, @@ -2419,7 +2510,7 @@ export default function ChatView({ threadId }: ChatViewProps) { message: { messageId: messageIdForSend, role: "user", - text: trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT, + text: outgoingMessageText, attachments: turnAttachments, }, model: selectedModel || undefined, @@ -2660,6 +2751,11 @@ export default function ChatView({ threadId }: ChatViewProps) { const threadIdForSend = activeThread.id; const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); + const outgoingMessageText = formatOutgoingPrompt({ + provider: selectedProvider, + effort: selectedEffort, + text: trimmed, + }); sendInFlightRef.current = true; beginSendPhase("sending-turn"); @@ -2669,7 +2765,7 @@ export default function ChatView({ threadId }: ChatViewProps) { { id: messageIdForSend, role: "user", - text: trimmed, + text: outgoingMessageText, createdAt: messageCreatedAt, streaming: false, }, @@ -2697,7 +2793,7 @@ export default function ChatView({ threadId }: ChatViewProps) { message: { messageId: messageIdForSend, role: "user", - text: trimmed, + text: outgoingMessageText, attachments: [], }, provider: selectedProvider, @@ -2741,6 +2837,7 @@ export default function ChatView({ threadId }: ChatViewProps) { persistThreadSettingsForNextTurn, resetSendPhase, runtimeMode, + selectedEffort, selectedModel, selectedModelOptionsForDispatch, providerOptionsForDispatch, @@ -2770,6 +2867,11 @@ export default function ChatView({ threadId }: ChatViewProps) { const nextThreadId = newThreadId(); const planMarkdown = activeProposedPlan.planMarkdown; const implementationPrompt = buildPlanImplementationPrompt(planMarkdown); + const outgoingImplementationPrompt = formatOutgoingPrompt({ + provider: selectedProvider, + effort: selectedEffort, + text: implementationPrompt, + }); const nextThreadTitle = truncateTitle(buildPlanImplementationThreadTitle(planMarkdown)); const nextThreadModel: ModelSlug = selectedModel || @@ -2806,7 +2908,7 @@ export default function ChatView({ threadId }: ChatViewProps) { message: { messageId: newMessageId(), role: "user", - text: implementationPrompt, + text: outgoingImplementationPrompt, attachments: [], }, provider: selectedProvider, @@ -2864,6 +2966,7 @@ export default function ChatView({ threadId }: ChatViewProps) { navigate, resetSendPhase, runtimeMode, + selectedEffort, selectedModel, selectedModelOptionsForDispatch, providerOptionsForDispatch, @@ -2882,7 +2985,11 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerDraftProvider(activeThread.id, provider); setComposerDraftModel( activeThread.id, - resolveAppModelSelection(provider, settings.customCodexModels, model), + resolveAppModelSelection( + provider, + getCustomModelSlugsForProvider(settings, provider), + model, + ), ); scheduleComposerFocus(); }, @@ -2892,11 +2999,11 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleComposerFocus, setComposerDraftModel, setComposerDraftProvider, - settings.customCodexModels, + settings, ], ); const onEffortSelect = useCallback( - (effort: CodexReasoningEffort) => { + (effort: ProviderReasoningEffort) => { setComposerDraftEffort(threadId, effort); scheduleComposerFocus(); }, @@ -3250,6 +3357,18 @@ export default function ChatView({ threadId }: ChatViewProps) { onToggleDiff={onToggleDiff} /> + {showAgentTeamsPanel ? ( +
+ +
+ ) : null} {/* Error banner */} @@ -3321,483 +3440,515 @@ export default function ChatView({ threadId }: ChatViewProps) { {/* Input bar */}
-
-
+ - {activePendingApproval ? ( -
- -
- ) : pendingUserInputs.length > 0 ? ( -
- -
- ) : showPlanFollowUpPrompt && activeProposedPlan ? ( -
- -
- ) : null} - - {/* Textarea area */}
- {composerMenuOpen && !isComposerApprovalState && ( -
- -
- )} - - {!isComposerApprovalState && - pendingUserInputs.length === 0 && - composerImages.length > 0 && ( -
- {composerImages.map((image) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} -
- )} - {nonPersistedComposerImageIdSet.has(image.id) && ( - - - - - } - /> - - Draft attachment could not be saved locally and may be lost on - navigation. - - - )} - -
- ))} -
- )} - -
- - {/* Bottom toolbar */} - {activePendingApproval ? ( -
- -
- ) : (
+ {activePendingApproval ? ( +
+ +
+ ) : pendingUserInputs.length > 0 ? ( +
+ +
+ ) : showPlanFollowUpPrompt && activeProposedPlan ? ( +
+ +
+ ) : null} + + {/* Textarea area */}
- {/* Provider/model picker */} - + + Ultrathink + +
+ ) : null} + {composerMenuOpen && !isComposerApprovalState && ( +
+ +
+ )} + + {!isComposerApprovalState && + pendingUserInputs.length === 0 && + composerImages.length > 0 && ( +
+ {composerImages.map((image) => ( +
+ {image.previewUrl ? ( + + ) : ( +
+ {image.name} +
+ )} + {nonPersistedComposerImageIdSet.has(image.id) && ( + + + + + } + /> + + Draft attachment could not be saved locally and may be lost on + navigation. + + + )} + +
+ ))} +
+ )} + +
- {isComposerFooterCompact ? ( - + - ) : ( - <> - {selectedProvider === "codex" && selectedEffort != null ? ( +
+ ) : ( +
+
+ {/* Provider/model picker */} + + + {isComposerFooterCompact ? ( + + ) : ( <> + {selectedEffort != null ? ( + <> + + + + ) : null} + - - - ) : null} - - - - - - - + - {activePlan || activeProposedPlan || planSidebarOpen ? ( - <> + - - ) : null} - - )} -
- {/* Right side: send / stop button */} -
- {isPreparingWorktree ? ( - - Preparing worktree... - - ) : null} - {activePendingProgress ? ( -
- {activePendingProgress.questionIndex > 0 ? ( - - ) : null} - + {activePlan || activeProposedPlan || planSidebarOpen ? ( + <> + + + + ) : null} + + )}
- ) : phase === "running" ? ( - - ) : pendingUserInputs.length === 0 ? ( - showPlanFollowUpPrompt ? ( - prompt.trim().length > 0 ? ( - - ) : ( -
+ {isPreparingWorktree ? ( + + Preparing worktree... + + ) : null} + {activePendingProgress ? ( +
+ {activePendingProgress.questionIndex > 0 ? ( + + ) : null} - - - } - > - - - - void onImplementPlanInNewThread()} - > - Implement in new thread - - -
- ) - ) : ( - + ) : pendingUserInputs.length === 0 ? ( + showPlanFollowUpPrompt ? ( + prompt.trim().length > 0 ? ( + + ) : ( +
+ + + + } + > + + + + void onImplementPlanInNewThread()} + > + Implement in new thread + + + +
+ ) ) : ( - - )} - - ) - ) : null} -
+ {isConnecting || isSendBusy ? ( + + ) : ( + + )} + + ) + ) : null} +
+
+ )}
- )} - - + + + {isGitRepo && ( diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 9a3ddbaa0..7d210fa17 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -146,7 +146,7 @@ export const OpenAI: Icon = (props) => ( export const ClaudeAI: Icon = (props) => ( diff --git a/apps/web/src/components/chat/AgentTeamsPanel.tsx b/apps/web/src/components/chat/AgentTeamsPanel.tsx new file mode 100644 index 000000000..828509b80 --- /dev/null +++ b/apps/web/src/components/chat/AgentTeamsPanel.tsx @@ -0,0 +1,606 @@ +import type { ServerProviderCapabilityState } from "@t3tools/contracts"; +import { + ChevronDownIcon, + ChevronUpIcon, + CircleAlertIcon, + LoaderCircleIcon, + PauseCircleIcon, + ShieldAlertIcon, + SquareDashedBottomCodeIcon, +} from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import type { + AgentTeamsActivity, + AgentTeamsMember, + AgentTeamsRun, + AgentTeamsState, + AgentTeamsTaskSnapshot, + AgentTeamsTaskStatus, +} from "../../session-logic"; +import { cn } from "~/lib/utils"; + +interface AgentTeamsPanelProps { + state: AgentTeamsState; + enabled: boolean; + capabilityState?: ServerProviderCapabilityState; + capabilityMessage?: string | null; +} + +const FALLBACK_TEAM_COLORS = [ + { + accent: "text-rose-700 dark:text-rose-200", + border: "border-rose-500/30", + surface: "bg-rose-500/8", + dot: "bg-rose-500", + }, + { + accent: "text-orange-700 dark:text-orange-200", + border: "border-orange-500/30", + surface: "bg-orange-500/8", + dot: "bg-orange-500", + }, + { + accent: "text-emerald-700 dark:text-emerald-200", + border: "border-emerald-500/30", + surface: "bg-emerald-500/8", + dot: "bg-emerald-500", + }, + { + accent: "text-sky-700 dark:text-sky-200", + border: "border-sky-500/30", + surface: "bg-sky-500/8", + dot: "bg-sky-500", + }, + { + accent: "text-violet-700 dark:text-violet-200", + border: "border-violet-500/30", + surface: "bg-violet-500/8", + dot: "bg-violet-500", + }, + { + accent: "text-fuchsia-700 dark:text-fuchsia-200", + border: "border-fuchsia-500/30", + surface: "bg-fuchsia-500/8", + dot: "bg-fuchsia-500", + }, +] as const; + +const COLOR_NAME_MAP: Record = { + red: 0, + rose: 0, + orange: 1, + amber: 1, + green: 2, + emerald: 2, + blue: 3, + sky: 3, + cyan: 3, + violet: 4, + purple: 4, + indigo: 4, + fuchsia: 5, + pink: 5, + magenta: 5, +}; + +function statusLabel(status: Exclude): string { + switch (status) { + case "running": + return "Running"; + case "idle": + return "Idle"; + case "awaitingApproval": + return "Needs approval"; + case "completed": + return "Completed"; + case "failed": + return "Failed"; + case "stopped": + return "Stopped"; + } +} + +function statusTone(status: Exclude): string { + switch (status) { + case "running": + return "border-sky-500/20 bg-sky-500/8 text-sky-700 dark:text-sky-200"; + case "idle": + return "border-amber-500/20 bg-amber-500/8 text-amber-700 dark:text-amber-200"; + case "awaitingApproval": + return "border-orange-500/30 bg-orange-500/10 text-orange-800 dark:text-orange-100"; + case "completed": + return "border-emerald-500/18 bg-emerald-500/6 text-emerald-700 dark:text-emerald-200"; + case "failed": + return "border-rose-500/25 bg-rose-500/8 text-rose-700 dark:text-rose-200"; + case "stopped": + return "border-zinc-500/25 bg-zinc-500/8 text-zinc-700 dark:text-zinc-200"; + } +} + +function StatusGlyph({ status }: { status: Exclude }) { + if (status === "running") { + return ; + } + if (status === "idle") { + return ; + } + if (status === "awaitingApproval") { + return ; + } + if (status === "failed" || status === "stopped") { + return ; + } + return ; +} + +function hashValue(value: string): number { + let result = 0; + for (let index = 0; index < value.length; index += 1) { + result = (result * 31 + value.charCodeAt(index)) >>> 0; + } + return result; +} + +function colorPresetForMember(member: AgentTeamsMember): (typeof FALLBACK_TEAM_COLORS)[number] { + const rawColor = member.agentColor?.trim().toLowerCase(); + if (rawColor) { + const directIndex = COLOR_NAME_MAP[rawColor]; + if (directIndex !== undefined) { + return FALLBACK_TEAM_COLORS[directIndex]!; + } + for (const [name, index] of Object.entries(COLOR_NAME_MAP)) { + if (rawColor.includes(name)) { + return FALLBACK_TEAM_COLORS[index]!; + } + } + } + return FALLBACK_TEAM_COLORS[hashValue(member.id) % FALLBACK_TEAM_COLORS.length]!; +} + +function formatTimestamp(value: string): string { + const parsed = Date.parse(value); + if (Number.isNaN(parsed)) { + return value; + } + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(parsed); +} + +function formatRelativeTime(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const minutes = Math.floor(diff / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} + +function formatRunSpan(run: AgentTeamsRun): string { + if (!run.endedAt) { + return `Started ${formatTimestamp(run.startedAt)}`; + } + return `${formatTimestamp(run.startedAt)} to ${formatTimestamp(run.endedAt)}`; +} + +function panelSummary(run: AgentTeamsRun): string { + const runningMembers = run.members.filter((member) => member.status === "running").length; + const idleMembers = run.members.filter((member) => member.status === "idle").length; + const parts = [`${run.members.length} agent${run.members.length === 1 ? "" : "s"}`]; + if (runningMembers > 0) parts.push(`${runningMembers} running`); + if (idleMembers > 0) parts.push(`${idleMembers} idle`); + return parts.join(" · "); +} + +function latestToolForMember(member: AgentTeamsMember): string | undefined { + if (member.status !== "running") return undefined; + const sorted = [...member.activities].toSorted((a, b) => + b.updatedAt.localeCompare(a.updatedAt), + ); + return sorted[0]?.lastToolName; +} + +interface UnifiedFeedEntry { + id: string; + updatedAt: string; + label: string; + detail?: string; + lastToolName?: string; + kind: "activity" | "task"; + status?: string; +} + +function buildUnifiedFeed( + member: AgentTeamsMember, + tasks: AgentTeamsTaskSnapshot[] | undefined, +): UnifiedFeedEntry[] { + const entries: UnifiedFeedEntry[] = []; + + for (const activity of member.activities) { + const entry: UnifiedFeedEntry = { + id: activity.id, + updatedAt: activity.updatedAt, + label: activity.label, + kind: "activity", + }; + if (activity.detail) entry.detail = activity.detail; + if (activity.lastToolName) entry.lastToolName = activity.lastToolName; + entries.push(entry); + } + + if (tasks) { + for (const task of tasks) { + if ( + task.teammateName && + task.teammateName.toLowerCase() !== member.label.toLowerCase() && + task.teammateName.toLowerCase() !== member.teammateName?.toLowerCase() && + task.teammateName.toLowerCase() !== member.agentName?.toLowerCase() + ) { + continue; + } + const taskEntry: UnifiedFeedEntry = { + id: `task:${task.taskId ?? task.summary ?? task.updatedAt ?? "unknown"}`, + updatedAt: task.updatedAt ?? member.startedAt, + label: task.summary ?? task.taskId ?? "Task", + kind: "task", + }; + if (task.status) taskEntry.status = task.status; + entries.push(taskEntry); + } + } + + return entries.toSorted((a, b) => b.updatedAt.localeCompare(a.updatedAt)); +} + +export function AgentTeamsPanel({ + state, + enabled, + capabilityState, + capabilityMessage, +}: AgentTeamsPanelProps) { + const [expanded, setExpanded] = useState(false); + const [selectedMemberId, setSelectedMemberId] = useState(null); + + const activeRun = useMemo( + () => + state.runs.find((run) => run.id === state.activeRunId) ?? + state.runs.find((run) => run.activeCount > 0) ?? + null, + [state.activeRunId, state.runs], + ); + const subjectRun = activeRun ?? state.runs[0] ?? null; + const archivedRuns = useMemo( + () => (subjectRun ? state.runs.filter((run) => run.id !== subjectRun.id) : []), + [state.runs, subjectRun], + ); + const selectedMember = useMemo( + () => + subjectRun?.members.find((member) => member.id === selectedMemberId) ?? + subjectRun?.members[0] ?? + null, + [selectedMemberId, subjectRun], + ); + const showWarning = + enabled && + capabilityState !== undefined && + capabilityState !== "available" && + capabilityMessage; + const selectedMemberPreset = selectedMember ? colorPresetForMember(selectedMember) : null; + + const unifiedFeed = useMemo( + () => (selectedMember ? buildUnifiedFeed(selectedMember, subjectRun?.tasks) : []), + [selectedMember, subjectRun?.tasks], + ); + + useEffect(() => { + if (!subjectRun) { + if (selectedMemberId !== null) { + setSelectedMemberId(null); + } + return; + } + const hasCurrentSelection = subjectRun.members.some((member) => member.id === selectedMemberId); + if (!hasCurrentSelection) { + setSelectedMemberId(subjectRun.members[0]?.id ?? null); + } + }, [selectedMemberId, subjectRun]); + + if (!state.hasTeamActivity || !subjectRun) { + return null; + } + + return ( +
+
+ {/* Collapsed / header bar */} +
+
+ + + +
+ + {expanded ? ( +
+

+ {formatRunSpan(subjectRun)} + {subjectRun.endedAt ? " · shut down" : ""} +

+ + {showWarning ? ( +
+ {capabilityMessage} +
+ ) : null} +
+ ) : null} +
+ + {/* Expanded: master-detail layout */} + {expanded ? ( +
+
+ {/* Left: agent list */} +
+ {subjectRun.members.map((member) => { + const preset = colorPresetForMember(member); + const isSelected = selectedMember?.id === member.id; + const currentTool = latestToolForMember(member); + return ( + + ); + })} + + {archivedRuns.length > 0 ? ( +
+

+ Earlier runs +

+ {archivedRuns.map((run) => ( +
+ {run.label} + + + +
+ ))} +
+ ) : null} +
+ + {/* Right: selected member detail + activity feed */} +
+ {selectedMember && selectedMemberPreset ? ( + <> + {/* Identity header */} +
+ + {selectedMember.label} + + + + {statusLabel(selectedMember.status)} + + + {selectedMember.agentType ? `${selectedMember.agentType} · ` : ""} + Started {formatRelativeTime(selectedMember.startedAt)} + {" · Updated "} + {formatRelativeTime(selectedMember.updatedAt)} + +
+ + {/* Current detail */} + {selectedMember.detail ? ( +

+ {selectedMember.detail} +

+ ) : null} + + {/* Unified activity feed */} +
+
+
+ {unifiedFeed.map((entry) => ( +
+ + + {formatRelativeTime(entry.updatedAt)} + +
+ {entry.label} + {entry.lastToolName ? ( + + [{entry.lastToolName}] + + ) : null} + {entry.kind === "task" && entry.status ? ( + + ({entry.status}) + + ) : null} + {entry.detail ? ( +

+ {entry.detail} +

+ ) : null} +
+
+ ))} + {unifiedFeed.length === 0 ? ( +

+ No activity recorded yet. +

+ ) : null} +
+
+ + ) : ( +
+ Select a teammate to view activity. +
+ )} +
+
+
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/chat/CodexTraitsPicker.tsx b/apps/web/src/components/chat/CodexTraitsPicker.tsx index 6c72f497b..529f2769e 100644 --- a/apps/web/src/components/chat/CodexTraitsPicker.tsx +++ b/apps/web/src/components/chat/CodexTraitsPicker.tsx @@ -1,4 +1,4 @@ -import { type CodexReasoningEffort } from "@t3tools/contracts"; +import { type ProviderKind, type ProviderReasoningEffort } from "@t3tools/contracts"; import { getDefaultReasoningEffort } from "@t3tools/shared/model"; import { memo, useState } from "react"; import { ChevronDownIcon } from "lucide-react"; @@ -13,24 +13,40 @@ import { MenuTrigger, } from "../ui/menu"; -export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { - effort: CodexReasoningEffort; - fastModeEnabled: boolean; - options: ReadonlyArray; - onEffortChange: (effort: CodexReasoningEffort) => void; - onFastModeChange: (enabled: boolean) => void; -}) { - const [isMenuOpen, setIsMenuOpen] = useState(false); - const defaultReasoningEffort = getDefaultReasoningEffort("codex"); - const reasoningLabelByOption: Record = { +function effortLabel(provider: ProviderKind, effort: ProviderReasoningEffort): string { + if (provider === "codex") { + const codexLabels: Record = { + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + }; + return codexLabels[effort] ?? effort; + } + + const claudeLabels: Record = { low: "Low", medium: "Medium", high: "High", - xhigh: "Extra High", + max: "Max", + ultrathink: "Ultrathink", }; + return claudeLabels[effort] ?? effort; +} + +export const ProviderTraitsPicker = memo(function ProviderTraitsPicker(props: { + provider: ProviderKind; + effort: ProviderReasoningEffort; + fastModeEnabled?: boolean; + options: ReadonlyArray; + onEffortChange: (effort: ProviderReasoningEffort) => void; + onFastModeChange?: (enabled: boolean) => void; +}) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const defaultReasoningEffort = getDefaultReasoningEffort(props.provider); const triggerLabel = [ - reasoningLabelByOption[props.effort], - ...(props.fastModeEnabled ? ["Fast"] : []), + effortLabel(props.provider, props.effort), + ...(props.provider === "codex" && props.fastModeEnabled ? ["Fast"] : []), ] .filter(Boolean) .join(" · "); @@ -56,7 +72,9 @@ export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { -
Reasoning
+
+ {props.provider === "codex" ? "Reasoning" : "Effort"} +
{ @@ -68,25 +86,29 @@ export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { > {props.options.map((effort) => ( - {reasoningLabelByOption[effort]} + {effortLabel(props.provider, effort)} {effort === defaultReasoningEffort ? " (default)" : ""} ))}
- - -
Fast Mode
- { - props.onFastModeChange(value === "on"); - }} - > - off - on - -
+ {props.provider === "codex" && props.onFastModeChange ? ( + <> + + +
Fast Mode
+ { + props.onFastModeChange?.(value === "on"); + }} + > + off + on + +
+ + ) : null}
); diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index 0af50ff01..6bca85efe 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -1,8 +1,8 @@ import { - type CodexReasoningEffort, type ProviderKind, RuntimeMode, ProviderInteractionMode, + type ProviderReasoningEffort, } from "@t3tools/contracts"; import { getDefaultReasoningEffort } from "@t3tools/shared/model"; import { memo } from "react"; @@ -24,22 +24,24 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls interactionMode: ProviderInteractionMode; planSidebarOpen: boolean; runtimeMode: RuntimeMode; - selectedEffort: CodexReasoningEffort | null; + selectedEffort: ProviderReasoningEffort | null; selectedProvider: ProviderKind; selectedCodexFastModeEnabled: boolean; - reasoningOptions: ReadonlyArray; - onEffortSelect: (effort: CodexReasoningEffort) => void; + reasoningOptions: ReadonlyArray; + onEffortSelect: (effort: ProviderReasoningEffort) => void; onCodexFastModeChange: (enabled: boolean) => void; onToggleInteractionMode: () => void; onTogglePlanSidebar: () => void; onToggleRuntimeMode: () => void; }) { - const defaultReasoningEffort = getDefaultReasoningEffort("codex"); - const reasoningLabelByOption: Record = { + const defaultReasoningEffort = getDefaultReasoningEffort(props.selectedProvider); + const reasoningLabelByOption: Record = { low: "Low", medium: "Medium", high: "High", xhigh: "Extra High", + max: "Max", + ultrathink: "Ultrathink", }; return ( @@ -57,10 +59,12 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls