diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 272d2cf10454c..d20753d9d1d62 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -7903,6 +7903,169 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("keeps a running turn's catalog through a refresh and a steer", () => + Effect.gen(function* () { + let catalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG; + const harness = yield* makeWakeHarnessWithOptions({ + modelCatalog: Effect.sync(() => catalog), + }); + const input = makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-catalog-refresh"), + text: "Hello", + attachments: [], + }); + yield* harness.runtime.startTurn(input); + const startWindow = harness.runtime.getModelContextWindow?.(CLAUDE_TEST_MODEL_SELECTION); + assert.isNumber(startWindow); + catalog = { models: [] }; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + yield* harness.runtime.steerTurn({ + threadId: harness.threadId, + runId: input.runId, + providerThread: harness.providerThread, + providerTurnId: idAllocator.derive.providerTurn({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeTurnId: `turn:${input.attemptId}`, + }), + message: { + createdBy: "user", + creationSource: "web", + messageId: MessageId.make("catalog-refresh-steer"), + text: "Keep going", + attachments: [], + }, + }); + const usage = { ...zeroUsage, input_tokens: 1_000, output_tokens: 2 }; + const frames = [ + usageStreamFrame({ type: "message_start", message: { id: "msg_refresh", usage } }), + claudeSdkFrame({ + type: "assistant", + parent_tool_use_id: null, + message: { + model: CLAUDE_TEST_MODEL_SELECTION.model, + id: "msg_refresh", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu-catalog-refresh", + name: "Agent", + input: { description: "Check", subagent_type: "general-purpose", prompt: "Check." }, + }, + ], + }, + uuid: "00000000-0000-4000-8000-000000000690", + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: "task-catalog-refresh", + tool_use_id: "toolu-catalog-refresh", + description: "Check", + task_type: "local_agent", + uuid: "00000000-0000-4000-8000-000000000691", + session_id: WAKE_NATIVE_SESSION, + }), + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000692", result: "OK" }), + ]; + for (const frame of frames) yield* Queue.offer(harness.sdkMessages, frame); + yield* Queue.take(harness.terminalReceipts); + + assert.deepEqual( + harness.offeredMessages.map((message) => message.message.content), + ["Ultrathink:\nHello", "Ultrathink:\nKeep going"], + ); + const windows = harness.events.flatMap((event) => + event.type === "provider_turn.updated" && event.providerTurn.tokenUsage + ? [event.providerTurn.tokenUsage.maxTokens] + : [], + ); + assert.deepEqual(windows, [startWindow]); + const subagent = harness.events.find((event) => event.type === "subagent.updated"); + assert.equal( + subagent?.type === "subagent.updated" + ? getModelSelectionStringOptionValue(subagent.subagent.modelSelection, "effort") + : undefined, + "high", + ); + assert.isUndefined(harness.runtime.getModelContextWindow?.(CLAUDE_TEST_MODEL_SELECTION)); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect("opens a turn's query with its catalog after a refresh during startup", () => + Effect.gen(function* () { + let catalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG; + let beforeMcpRead: Effect.Effect = Effect.void; + const harness = yield* makeWakeHarnessWithOptions({ + modelCatalog: Effect.sync(() => catalog), + }).pipe( + Effect.updateService(McpProviderSessions.McpProviderSessions, (sessions) => ({ + ...sessions, + read: (threadId) => Effect.andThen(beforeMcpRead, sessions.read(threadId)), + })), + ); + const reached = yield* Deferred.make(); + const release = yield* Deferred.make(); + beforeMcpRead = Deferred.succeed(reached, undefined).pipe( + Effect.andThen(Deferred.await(release)), + ); + const input = makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-catalog-startup"), + text: "Hello", + attachments: [], + }); + const starting = yield* harness.runtime.startTurn(input).pipe(Effect.forkChild); + yield* Deferred.await(reached); + catalog = { models: [] }; + // Any steer reloads the adapter-wide catalog, even one that then finds no live query. + yield* harness.runtime + .steerTurn({ + threadId: harness.threadId, + runId: input.runId, + providerThread: harness.providerThread, + providerTurnId: ProviderTurnId.make("provider-turn-catalog-startup"), + message: { + createdBy: "user", + creationSource: "web", + messageId: MessageId.make("catalog-startup-steer"), + text: "Keep going", + attachments: [], + }, + }) + .pipe(Effect.flip); + assert.isUndefined(harness.runtime.getModelContextWindow?.(CLAUDE_TEST_MODEL_SELECTION)); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(starting); + assert.equal(harness.getOpenedOptions()?.effort, "high"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + it.effect.each(["root", "mcp", "subagent", "remote"] as const)( "asks Claude for suggestions only on threads a person drives (%s)", (lineage) => diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index be938673b5502..481dc1cb16957 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -4331,6 +4331,8 @@ function formatClaudeUsageLimitWait(waitMs: number): string { interface ActiveClaudeTurnContext { readonly queryPolicy: ClaudeRuntimeQueryPolicy; readonly input: ProviderAdapter.ProviderAdapterV2TurnInput; + // Snapshot taken at turn start; a later refresh applies from the next turn. + readonly modelCatalog: ClaudeModelCatalog; readonly autoCompactWindow: string; readonly nativeThreadId: string; readonly nativeTurnId: string; @@ -4882,11 +4884,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( let modelCatalog = yield* loadModelCatalog; // Results report the window Claude Code compacts against; outside the catalog it is the only source. const reportedContextWindows = new Map(); - const claudeContextWindow = (modelSelection: ModelSelection): number | null => - resolveClaudeCatalogContextWindowTokens(modelCatalog, modelSelection) ?? - reportedContextWindows.get( - compileClaudeModelSelection(modelSelection, modelCatalog).apiModelId, - ) ?? + const claudeContextWindow = ( + catalog: ClaudeModelCatalog, + modelSelection: ModelSelection, + ): number | null => + resolveClaudeCatalogContextWindowTokens(catalog, modelSelection) ?? + reportedContextWindows.get(compileClaudeModelSelection(modelSelection, catalog).apiModelId) ?? null; const continuationRequests = adapterOptions.continuationRequests ?? { offer: () => Effect.void, @@ -5464,7 +5467,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // Only message_start marks the request start; an assistant snapshot // arrives after generation, so it leaves the cache expiry unknown. if (requestStarted && context.streamUsage !== null) context.streamUsage.requestedAt = now; - const maxTokens = claudeContextWindow(context.input.modelSelection); + const maxTokens = claudeContextWindow(context.modelCatalog, context.input.modelSelection); const tokenUsage = typeof usage === "number" ? { @@ -6867,8 +6870,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( existingSubagent === undefined ? (input.effort ?? input.owner?.effort ?? - compileClaudeModelSelection(input.context.input.modelSelection, modelCatalog) - .effort) + compileClaudeModelSelection( + input.context.input.modelSelection, + input.context.modelCatalog, + ).effort) : existingSubagent.effort; const model = input.model ?? priorTask?.model ?? null; const task = { @@ -6906,7 +6911,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ...(existingSubagent === undefined || existingSubagent.task.modelSelection !== undefined ? { modelSelection: claudeSubagentModelSelection( - modelCatalog, + input.context.modelCatalog, input.context.input.modelSelection.instanceId, model || input.context.input.modelSelection.model, effort, @@ -9166,7 +9171,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (message.message.model && message.message.model !== "") { context.rootModels.add(message.message.model); context.executedModel = resolveClaudeReportedModelSlug( - modelCatalog, + context.modelCatalog, message.message.model, ); } @@ -9450,7 +9455,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const model = snapshotModel.length === 0 || snapshotModel === "" ? undefined - : canonicalClaudeSubagentModel(modelCatalog, snapshotModel); + : canonicalClaudeSubagentModel(context.modelCatalog, snapshotModel); if (parentToolUseId !== null && model !== undefined) { const subagent = yield* resolveSubagentByToolUseId(context, parentToolUseId); if (subagent === undefined) { @@ -9756,7 +9761,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const nativeToolInput = claudeNativeToolInputFromUnknown(toolUse.input); if (toolUse.name === "Agent") { rememberClaudeSubagentLaunch( - modelCatalog, + context.modelCatalog, context, pendingSubagentLaunchesByToolUseId, toolUse.id, @@ -10356,7 +10361,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const startToolCall = toolName !== "Agent" && options?.startToolCall !== false; if (toolName === "Agent") { rememberClaudeSubagentLaunch( - modelCatalog, + context.modelCatalog, context, pendingSubagentLaunchesByToolUseId, nativeRequestId, @@ -10857,6 +10862,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const openQuery = Effect.fnUntraced(function* ( turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, nativeThreadId: string, + turnCatalog: ClaudeModelCatalog, ) { const queryPolicy = yield* queryPolicyForTurn(turnInput); const mcpSession = yield* mcpSessions.read(turnInput.threadId); @@ -10871,7 +10877,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const queryPolicyKey = claudeEffectiveQueryPolicyKey(queryPolicy, mcpOverrides); const compiledSelection = compileClaudeModelSelection( turnInput.modelSelection, - modelCatalog, + turnCatalog, ); const resumeSessionAt = yield* getNativeConversationHeadId(turnInput.providerThread); const existing = yield* Ref.get(queryContext); @@ -10987,7 +10993,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ); const queryOptions = makeClaudeQueryOptions({ modelSelection: turnInput.modelSelection, - modelCatalog, + modelCatalog: turnCatalog, nativeThreadId, title: turnInput.appThread.title, resume: shouldResume, @@ -11182,7 +11188,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const startTurn = Effect.fn("ClaudeAdapterV2.startTurn")( function* (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) { - modelCatalog = yield* loadModelCatalog; + const turnCatalog = yield* loadModelCatalog; + modelCatalog = turnCatalog; const startedAt = yield* DateTime.now; const nativeThreadId = yield* getNativeThreadId(turnInput.providerThread); const nativeTurnId = `turn:${turnInput.attemptId}`; @@ -11241,7 +11248,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( : yield* makeClaudeUserMessageWithAttachments({ text: applyClaudePromptEffortPrefix( turnInput.message.text, - compileClaudeModelSelection(turnInput.modelSelection, modelCatalog) + compileClaudeModelSelection(turnInput.modelSelection, turnCatalog) .promptEffort, ), attachments: turnInput.message.attachments, @@ -11254,7 +11261,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(Path.Path, path), ); - const querySession = yield* openQuery(turnInput, nativeThreadId); + const querySession = yield* openQuery(turnInput, nativeThreadId, turnCatalog); const autoCompactWindow = yield* resolveAutoCompactWindow( querySession.settingsCwd, turnInput.modelSelection, @@ -11262,6 +11269,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ); const context: ActiveClaudeTurnContext = { input: turnInput, + modelCatalog: turnCatalog, autoCompactWindow, queryPolicy: querySession.queryPolicy, nativeThreadId, @@ -11699,8 +11707,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const userMessage = yield* makeClaudeUserMessageWithAttachments({ text: applyClaudePromptEffortPrefix( turnInput.message.text, - compileClaudeModelSelection(currentTurn.input.modelSelection, modelCatalog) - .promptEffort, + compileClaudeModelSelection( + currentTurn.input.modelSelection, + currentTurn.modelCatalog, + ).promptEffort, ), attachments: turnInput.message.attachments, priority: "now", @@ -11855,7 +11865,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( driver: CLAUDE_PROVIDER, providerSessionId: input.providerSessionId, providerSession: session, - getModelContextWindow: (selection) => claudeContextWindow(selection) ?? undefined, + getModelContextWindow: (selection) => + claudeContextWindow(modelCatalog, selection) ?? undefined, events: Stream.fromEffectRepeat(Queue.take(events)), backgroundTasksEndWithProcess: true, hasPendingBackgroundWork: Effect.gen(function* () {