From f633195862f9fcaa89f96be12a3a8b8b41480f78 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 17 Sep 2026 19:25:18 -0700 Subject: [PATCH 01/13] feat(server): preserve budgeted history across provider handoffs --- apps/server/src/mcp/OrchestratorMcpService.ts | 25 +- ...OrchestratorMcpToolkit.integration.test.ts | 66 +++ .../src/mcp/toolkits/orchestrator/tools.ts | 2 +- .../Adapters/CodexAdapterV2.test.ts | 104 ++++ .../Adapters/CodexAdapterV2.ts | 28 + .../ContextHandoffBudget.test.ts | 365 +++++++++++++ .../orchestration-v2/ContextHandoffBudget.ts | 181 +++++++ .../ContextHandoffDelivery.ts | 135 +++++ .../orchestration-v2/ContextHandoffService.ts | 92 ++-- .../src/orchestration-v2/Orchestrator.ts | 16 +- .../src/orchestration-v2/ProviderAdapter.ts | 12 + .../ProviderTurnStartService.ts | 253 +++++++-- .../src/orchestration-v2/WireProjection.ts | 13 +- .../ProviderSwitch.integration.test.ts | 501 ++++++++++++------ .../ThreadMergeBack.integration.test.ts | 74 ++- docs/user/portable-handoffs.md | 50 +- docs/user/thread-migration.md | 8 +- packages/contracts/src/orchestrationV2.ts | 28 + packages/contracts/src/orchestratorMcp.ts | 3 + 19 files changed, 1702 insertions(+), 254 deletions(-) create mode 100644 apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts create mode 100644 apps/server/src/orchestration-v2/ContextHandoffBudget.ts create mode 100644 apps/server/src/orchestration-v2/ContextHandoffDelivery.ts diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 0643db464911..4c58a5854c46 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -701,10 +701,13 @@ function turnItemText(item: OrchestrationV2TurnItem): string | null { function timelineItem(input: { readonly row: OrchestrationV2ThreadProjection["visibleTurnItems"][number]; readonly maxChars: number; + readonly textOffset?: number; readonly messagesByThreadId: ReadonlyMap; }): OrchestratorMcpThreadTimelineItem { const text = turnItemText(input.row.item); - const textTruncated = text !== null && text.length > input.maxChars; + const offset = input.textOffset ?? 0; + const end = offset + input.maxChars; + const textTruncated = text !== null && text.length > end; const messageId = input.row.item.type === "user_message" || input.row.item.type === "assistant_message" ? input.row.item.messageId @@ -727,8 +730,9 @@ function timelineItem(input: { type: input.row.item.type, status: input.row.item.status, title: input.row.item.title, - text: textTruncated ? `${text.slice(0, input.maxChars)}\nโ€ฆ[truncated]` : text, + text: text === null ? null : text.slice(offset, end), textTruncated, + nextTextOffset: textTruncated ? end : null, updatedAt: DateTime.formatIso(input.row.item.updatedAt), }; } @@ -1659,9 +1663,14 @@ const make = Effect.gen(function* () { const limit = input.limit ?? DEFAULT_THREAD_READ_LIMIT; const maxChars = input.maxCharsPerItem ?? DEFAULT_THREAD_ITEM_MAX_CHARS; const matching = target.visibleTurnItems - .filter((row) => row.position > afterPosition) + .filter((row) => + input.itemId === undefined + ? row.position > afterPosition + : row.sourceItemId === input.itemId, + ) .filter( (row) => + input.itemId !== undefined || view === "activity" || row.item.type === "user_message" || row.item.type === "assistant_message" || @@ -1692,6 +1701,7 @@ const make = Effect.gen(function* () { const task = directAppOwnedChildTask(parent, target); if ( task !== undefined && + (input.textOffset ?? 0) === 0 && pageIncludesTerminalTaskResult({ parent, page, task, target, maxChars }) ) { yield* readTask(scope, task.id, false, true, "thread-read-acknowledge"); @@ -1702,7 +1712,14 @@ const make = Effect.gen(function* () { .toSorted((left, right) => right.ordinal - left.ordinal) .slice(0, input.runLimit ?? DEFAULT_THREAD_RUN_LIMIT) .map(threadRun), - items: page.map((row) => timelineItem({ row, maxChars, messagesByThreadId })), + items: page.map((row) => + timelineItem({ + row, + maxChars, + messagesByThreadId, + ...(input.itemId === undefined ? {} : { textOffset: input.textOffset ?? 0 }), + }), + ), nextPosition: page.at(-1)?.position ?? null, hasMore: page.length < matching.length, } satisfies OrchestratorMcpThreadReadResult; diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 24f1935a2f76..3a64a87a7152 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; import { CommandId, EnvironmentId, + EventId, IsoDateTime, MessageId, type ModelSelection, @@ -47,6 +48,7 @@ import { McpSchema, McpServer } from "effect/unstable/ai"; import { ClaudeProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; import { CodexOrchestratorReplayHarness } from "../orchestration-v2/Adapters/CodexAdapterV2.testkit.ts"; +import { EventSinkV2 } from "../orchestration-v2/EventSink.ts"; import { OrchestratorV2, type OrchestratorV2Shape } from "../orchestration-v2/Orchestrator.ts"; import { layer as threadManagementServiceLayer } from "../orchestration-v2/ThreadManagementService.ts"; import { @@ -1009,6 +1011,28 @@ describe("orchestrator MCP toolkit", () => { )?.completionDelivery?.state, ).toBe("claimed"); + const firstChunk = truncatedResultRead.items[0]!; + expect(firstChunk.nextTextOffset).toBe(1); + const remainderCall = yield* invoke("t3_thread_read", { + threadId: directChildThreadId, + itemId: firstChunk.itemId, + textOffset: firstChunk.nextTextOffset, + maxCharsPerItem: 50_000, + }); + const remainder = yield* decodeThreadReadResult(remainderCall.structuredContent).pipe( + Effect.orDie, + ); + expect(remainder.items[0]?.nextTextOffset).toBeNull(); + expect(firstChunk.text + (remainder.items[0]?.text ?? "")).toBe( + "Claude completed: Complete before a parent reads this child result directly.", + ); + // Reading a suffix alone cannot acknowledge a whole child result. + expect( + (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( + (task) => task.id === directRead.task.id, + )?.completionDelivery?.state, + ).toBe("claimed"); + const terminalResultReadCall = yield* invoke("t3_thread_read", { threadId: directChildThreadId, afterPosition: childPromptRead.nextPosition, @@ -1024,6 +1048,48 @@ describe("orchestrator MCP toolkit", () => { textTruncated: false, }, ]); + const longText = "็•Œ๐Ÿงช\n".repeat(20_000) + "FINAL_CONSTRAINT"; + const childProjection = yield* orchestrator.getThreadProjection(directChildThreadId); + const sourceItem = childProjection.turnItems.find( + (item) => item.type === "user_message", + )!; + const oversizedItem = { + ...sourceItem, + id: TurnItemId.make("item:oversized-retrieval"), + type: "assistant_message" as const, + text: longText, + streaming: false, + ordinal: 999, + }; + yield* (yield* EventSinkV2).write({ + events: [ + { + id: EventId.make("event:oversized-retrieval"), + type: "turn-item.updated", + threadId: directChildThreadId, + occurredAt: yield* DateTime.now, + payload: oversizedItem, + }, + ], + }); + let recovered = ""; + let textOffset: number | null = 0; + while (textOffset !== null) { + const pageCall = yield* invoke("t3_thread_read", { + threadId: directChildThreadId, + itemId: oversizedItem.id, + textOffset, + maxCharsPerItem: 50_000, + }); + const page: OrchestratorMcpThreadReadResult = yield* decodeThreadReadResult( + pageCall.structuredContent, + ).pipe(Effect.orDie); + expect(page.items).toHaveLength(1); + recovered += page.items[0]!.text; + textOffset = page.items[0]!.nextTextOffset ?? null; + } + expect(recovered).toBe(longText); + const acknowledgedByDirectRead = yield* waitForProjection( orchestrator, parentThreadId, diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 3b155fd4d4e2..d1f712e75b8b 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -170,7 +170,7 @@ const ThreadListTool = Tool.make("t3_thread_list", { const ThreadReadTool = Tool.make("t3_thread_read", { description: - "Read durable state and a paginated timeline from a T3 thread in the calling project, or from a thread the user attached to this conversation as context. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition.", + "Read durable state and a paginated timeline from a T3 thread in the calling project, or from a thread the user attached to this conversation as context. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units.", parameters: OrchestratorMcpThreadReadInput, success: OrchestratorMcpThreadReadResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 71c87c157a99..c6c157e855b8 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -1,3 +1,5 @@ +import { historyResponseItems } from "../ContextHandoffBudget.ts"; +import type { ProviderAdapterV2HistoricalContext } from "../ProviderAdapter.ts"; import { makeProviderTextDeltaCoalescer, type ProviderTextDeltaUpdate, @@ -22,6 +24,7 @@ import { RunAttemptId, RunId, ThreadId, + TurnItemId, } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -31,6 +34,7 @@ import * as CodexReplay from "effect-codex-app-server/replay"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Predicate from "effect/Predicate"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -1540,6 +1544,106 @@ describe("CodexAdapterV2 post-settle continuation", () => { }; }); + for (const response of ["supported", "unsupported", "invalid"] as const) { + it.effect(`delivers native history with ${response} app-server protocol`, () => + Effect.gen(function* () { + const nativeThreadId = `inject-${response}`; + const prompt = "Only the current request"; + const history: ProviderAdapterV2HistoricalContext = { + context: "Historical conversation", + messages: (["user", "assistant"] as const).map((role) => ({ + role, + text: + role === "user" + ? "Original request\n" + "็•Œ".repeat(300) + : "Partial interrupted work", + threadId: ThreadId.make("source"), + runId: RunId.make("source-run"), + itemId: TurnItemId.make(`source-${role}`), + providerThreadId: null, + kind: `${role}_message`, + status: "interrupted", + })), + }; + const items = historyResponseItems(history.messages, history.context); + const preamble = codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "current-turn", + prompt, + }); + const transcript = makeCodexReplayTranscript({ + scenario: `inject-${response}`, + entries: [ + ...preamble.slice(0, -3), + { + type: "expect_outbound", + label: "inject", + frame: { + id: 3, + method: "thread/inject_items", + params: { threadId: nativeThreadId, items }, + }, + }, + { + type: "emit_inbound", + label: "inject-result", + frame: + response === "supported" + ? { id: 3, result: {} } + : { + id: 3, + error: { + code: response === "unsupported" ? -32601 : -32602, + message: "Injection rejected", + }, + }, + }, + ...(response === "invalid" + ? [] + : preamble + .slice(-3) + .map((entry) => + "frame" in entry && Predicate.isObject(entry.frame) && "id" in entry.frame + ? { ...entry, frame: { ...entry.frame, id: 4 } } + : entry, + )), + ], + }); + const requests: string[] = []; + const harness = yield* makeCodexReplayHarness( + transcript, + () => Effect.void, + (method) => + Effect.sync(() => { + requests.push(method); + }), + ); + const injection = yield* harness.runtime.injectHistory!({ + providerThread: harness.providerThread, + ...history, + }).pipe(Effect.result); + if (response === "invalid") { + assert.equal(injection._tag, "Failure"); + assert.notInclude(requests, "turn/start"); + return; + } + assert.equal(injection._tag, "Success"); + if (injection._tag === "Success") assert.equal(injection.success, response === "supported"); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("inject-attempt"), + text: prompt, + }), + ); + assert.equal(requests.filter((method) => method === "turn/start").length, 1); + assert.isBelow(requests.indexOf("thread/inject_items"), requests.indexOf("turn/start")); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ); + } + it.effect("waits for native start before interrupting an acknowledged queued turn", () => Effect.gen(function* () { const nativeThreadId = "early-stop-thread"; diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index a1da702eff14..6d18abfdaa17 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -1,3 +1,4 @@ +import { historyResponseItems } from "../ContextHandoffBudget.ts"; import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts"; import { mcpToolPresentation, @@ -5075,6 +5076,33 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi }), ), ), + injectHistory: (input) => + Effect.gen(function* () { + const threadId = yield* getNativeThreadId(input.providerThread); + return yield* client + .request("thread/inject_items", { + threadId, + items: historyResponseItems(input.messages, input.context), + }) + .pipe( + Effect.as(true), + // Older app servers reject unknown methods before mutating history. + // Transport errors and invalid payloads are ambiguous and must not + // fall through to a second delivery in the current user message. + Effect.catchTag("CodexAppServerRequestError", (error) => + error.code === -32601 ? Effect.succeed(false) : Effect.fail(error), + ), + ); + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProtocolError({ + driver: CODEX_PROVIDER, + detail: "Failed to inject historical context", + payload: cause, + }), + ), + ), startTurn: (turnInput) => Effect.gen(function* () { const threadId = yield* getNativeThreadId(turnInput.providerThread); diff --git a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts new file mode 100644 index 000000000000..e45f14bc08e0 --- /dev/null +++ b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts @@ -0,0 +1,365 @@ +import type { ProviderAdapterV2HistoricalContext } from "./ProviderAdapter.ts"; +import { assert, describe, it } from "@effect/vitest"; +import { + ContextHandoffId, + ProviderDriverKind, + ProviderInstanceId, + ProviderThreadId, + RunId, + ThreadId, + TurnItemId, + OrchestrationV2ContextHandoff, + type OrchestrationV2HistoricalMessage, + type OrchestrationV2ProviderThread, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { + handoffBudget, + historyCost, + historyResponseItems, + selectHistory, + historicalMessage, +} from "./ContextHandoffBudget.ts"; +import { projectContextHandoffForWire } from "./WireProjection.ts"; +import { deliverContextHandoffs } from "./ContextHandoffDelivery.ts"; + +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); +const decodeHandoff = Schema.decodeUnknownSync(OrchestrationV2ContextHandoff); + +const now = DateTime.makeUnsafe("2026-09-17T00:00:00Z"); +const threadId = ThreadId.make("thread:handoff"); +const providerThread: OrchestrationV2ProviderThread = { + id: ProviderThreadId.make("provider-thread:target"), + driver: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + providerSessionId: null, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: { + driver: ProviderDriverKind.make("codex"), + strength: "strong", + nativeId: "native:target", + }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, +}; +const message = ( + id: string, + role: "user" | "assistant", + text: string, +): OrchestrationV2HistoricalMessage => ({ + itemId: TurnItemId.make(id), + role, + text, + threadId, + runId: RunId.make("run:source"), + providerThreadId: ProviderThreadId.make("provider-thread:source"), + status: "interrupted", + kind: role === "user" ? "user_message" : "assistant_message", +}); +const messages = [ + message("item:one", "user", "Preserve every line.\n\n And this indentation.\n"), + message("item:two", "assistant", "Partial work: ๆ—ฅๆœฌ่ชž ๐Ÿงช ู…ุฑุญุจุง\n" + "x".repeat(600)), +]; +const handoff: OrchestrationV2ContextHandoff = { + id: ContextHandoffId.make("handoff:one"), + threadId, + targetRunId: RunId.make("run:target"), + fromProviderThreadIds: [], + toProviderThreadId: providerThread.id, + coveredRunOrdinals: { from: 1, to: 2 }, + strategy: "full_thread_summary", + status: "ready", + summaryMessageId: null, + summaryText: "", + history: { + messages, + coverage: "Historical context; retrieve thread:handoff with t3_thread_read.", + omittedItems: 0, + }, + createdByProviderInstanceId: null, + createdAt: now, + updatedAt: now, +}; + +describe("handoff budget", () => { + it("keeps old preview handoffs readable and delivery history off the wire", () => { + const projected = projectContextHandoffForWire({ + ...handoff, + delivery: { + nativeThreadId: "native:target", + status: "injected", + itemIds: [messages[0]!.itemId], + }, + }); + assert.isUndefined(projected.history); + assert.isUndefined(projected.delivery); + assert.equal(decodeHandoff(projected).id, handoff.id); + assert.deepEqual(handoff.history?.messages, messages); + }); + it("carries command outcomes as attributed activity and excludes reasoning", () => { + const base = { + id: TurnItemId.make("item:command"), + threadId, + runId: RunId.make("run:source"), + nodeId: null, + providerThreadId: providerThread.id, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "failed" as const, + title: null, + startedAt: now, + completedAt: now, + updatedAt: now, + }; + const command = historicalMessage({ + ...base, + type: "command_execution", + input: "vp test", + output: "Failure near the end: " + "็•Œ".repeat(300), + exitCode: 1, + }); + assert.equal(command?.role, "assistant"); + assert.equal(command?.kind, "command_execution"); + assert.include(command!.text, "Exit code: 1"); + assert.include(command!.text, "็•Œ".repeat(300)); + assert.isNull( + historicalMessage({ + ...base, + type: "reasoning", + text: "private reasoning", + streaming: false, + }), + ); + assert.equal(historyResponseItems([command!], "Activity")[1]?.type, "message"); + }); + + it("retains short conversations verbatim in role and order", () => { + const selected = selectHistory({ messages, coverage: "History", budget: 16_000 }); + assert.deepEqual(selected.messages, messages); + const items = historyResponseItems(selected.messages, selected.context); + assert.deepEqual( + items.map((item) => item.role), + ["user", "user", "assistant"], + ); + assert.include(items[1]!.content[0]!.text, messages[0]!.text); + assert.include(items[2]!.content[0]!.text, messages[1]!.text); + assert.equal(selected.omittedItems, 0); + }); + + it("omits oversized multilingual items whole, preserves original constraints and recent work", () => { + const candidates = [ + messages[0]!, + message("huge", "assistant", "็•Œ๐Ÿงช".repeat(20_000)), + message("old", "assistant", "a".repeat(4_000)), + messages[1]!, + ]; + const selected = selectHistory({ + messages: candidates, + coverage: "thread:handoff runs 1-4", + budget: 3_000, + }); + assert.deepEqual(selected.messages, messages); + assert.equal(selected.omittedItems, 2); + assert.isAtMost(historyCost(selected.messages, selected.context), 3_000); + }); + + it("counts JSON escaping and UTF-8 bytes across many messages", () => { + const candidates = Array.from({ length: 500 }, (_, i) => + message(`item:${i}`, i % 2 ? "assistant" : "user", '\u0000\\"๐Ÿงช็•Œ'.repeat(15)), + ); + for (const budget of [1_024, 4_000, 16_000]) { + const selected = selectHistory({ + messages: candidates, + coverage: "Retrieve omitted history", + budget, + }); + assert.isAtMost(historyCost(selected.messages, selected.context), budget); + assert.isAbove(selected.omittedItems, 0); + } + }); + + it("subtracts native usage, input, attachments, instructions and space for work", () => { + const base = { + tokenCap: 16_000, + userText: "Continue", + attachments: [], + providerThread, + nativeContextBytes: 0, + }; + assert.isBelow(handoffBudget(base), 16_000); + assert.isBelow(handoffBudget({ ...base, nativeContextBytes: 8_000 }), handoffBudget(base)); + assert.equal(handoffBudget({ ...base, userText: "็•Œ".repeat(30_000) }), 0); + assert.equal( + handoffBudget({ + ...base, + providerThread: { + ...providerThread, + contextUsage: { usedTokens: 23_000, maxTokens: 24_000 }, + }, + }), + 0, + ); + assert.equal( + handoffBudget({ + ...base, + attachments: [ + { + type: "image", + id: "attachment", + name: "image.png", + mimeType: "image/png", + sizeBytes: 40_000, + }, + ], + }), + 0, + ); + assert.equal(handoffBudget({ ...base, tokenCap: 2_000 }), 2_000); + }); +}); + +describe("handoff delivery", () => { + it.effect("persists successful injection before turn start and skips it on retry", () => + Effect.gen(function* () { + let durable = handoff; + const history: unknown[] = []; + const statuses: string[] = []; + const input = { + providerThread, + budget: 16_000, + alreadyDeliveredItemIds: new Set(), + inject: (value: ProviderAdapterV2HistoricalContext) => + Effect.sync(() => { + history.push(...historyResponseItems(value.messages, value.context)); + return true; + }), + persist: (value: OrchestrationV2ContextHandoff) => + Effect.sync(() => { + durable = value; + statuses.push(value.delivery!.status); + }), + }; + const first = yield* deliverContextHandoffs({ ...input, handoffs: [durable] }); + assert.equal(first.context, ""); + assert.deepEqual(statuses, ["pending", "injected"]); + assert.lengthOf(history, 3); + // No turn has started. Reconstruct from the durable projection as a retry would. + const second = yield* deliverContextHandoffs({ ...input, handoffs: [durable] }); + assert.equal(second.context, ""); + assert.lengthOf(history, 3); + }), + ); + + it.effect( + "uses the same selection on explicit native fallback, marking only accepted input", + () => + Effect.gen(function* () { + let durable = handoff; + const result = yield* deliverContextHandoffs({ + handoffs: [handoff], + providerThread, + budget: 16_000, + alreadyDeliveredItemIds: new Set(), + inject: () => Effect.succeed(false), + persist: (value) => + Effect.sync(() => { + durable = value; + }), + }); + assert.include(result.context, messages[0]!.text); + assert.include(result.context, messages[1]!.text); + assert.equal(durable.delivery?.status, "pending"); + yield* result.delivered; + assert.equal(durable.delivery?.status, "inline"); + }), + ); + + it.effect("does not redeliver after ambiguous injection failure", () => + Effect.gen(function* () { + let durable = handoff; + let calls = 0; + const input = { + providerThread, + budget: 16_000, + alreadyDeliveredItemIds: new Set(), + inject: () => + Effect.sync(() => { + calls++; + }).pipe(Effect.andThen(Effect.fail("connection lost"))), + persist: (value: OrchestrationV2ContextHandoff) => + Effect.sync(() => { + durable = value; + }), + }; + yield* deliverContextHandoffs({ ...input, handoffs: [durable] }).pipe(Effect.result); + assert.equal(durable.delivery?.status, "pending"); + const retry = yield* deliverContextHandoffs({ ...input, handoffs: [durable] }).pipe( + Effect.result, + ); + assert.equal(retry._tag, "Failure"); + assert.equal(calls, 1); + }), + ); + + it.effect("budgets multiple handoffs together and skips previously delivered items", () => + Effect.gen(function* () { + const extra = { + ...handoff, + id: ContextHandoffId.make("handoff:two"), + history: { + ...handoff.history!, + messages: [...messages, message("item:three", "assistant", "Latest result")], + }, + }; + let delivered: ReadonlyArray = []; + yield* deliverContextHandoffs({ + handoffs: [handoff, extra], + providerThread, + budget: 2_500, + alreadyDeliveredItemIds: new Set(["item:one"]), + inject: (value) => + Effect.sync(() => { + delivered = historyResponseItems(value.messages, value.context); + return true; + }), + persist: () => Effect.void, + }); + const serialized = yield* encodeJson(delivered); + assert.isAtMost(Buffer.byteLength(serialized), 2_500); + assert.notInclude(serialized, "Preserve every line"); + assert.equal(serialized.split("Partial work").length - 1, 1); + assert.include(serialized, "Latest result"); + }), + ); + + it.effect("fails before delivery when even the coverage marker cannot fit", () => + Effect.gen(function* () { + let calls = 0; + const result = yield* deliverContextHandoffs({ + handoffs: [handoff], + providerThread, + budget: 0, + alreadyDeliveredItemIds: new Set(), + inject: () => + Effect.sync(() => { + calls++; + return true; + }), + persist: () => Effect.void, + }).pipe(Effect.result); + assert.equal(result._tag, "Failure"); + assert.equal(calls, 0); + }), + ); +}); diff --git a/apps/server/src/orchestration-v2/ContextHandoffBudget.ts b/apps/server/src/orchestration-v2/ContextHandoffBudget.ts new file mode 100644 index 000000000000..cf12ab45c207 --- /dev/null +++ b/apps/server/src/orchestration-v2/ContextHandoffBudget.ts @@ -0,0 +1,181 @@ +import type { + ChatAttachment, + OrchestrationV2ContextHandoff, + OrchestrationV2HistoricalMessage, + OrchestrationV2ProviderThread, + OrchestrationV2TurnItem, +} from "@t3tools/contracts"; + +import * as Config from "effect/Config"; + +export const DEFAULT_HANDOFF_TOKEN_CAP = 16_000; +export const HANDOFF_BYTE_CAP = 64_000; +export const handoffTokenCapConfig = Config.int("T3CODE_CONTEXT_HANDOFF_TOKEN_CAP").pipe( + Config.withDefault(DEFAULT_HANDOFF_TOKEN_CAP), + Config.map((value) => Math.max(1_024, Math.min(HANDOFF_BYTE_CAP, value))), +); + +// One UTF-8 byte per token is deliberately pessimistic for byte-based tokenizers, +// including multilingual text. It is not a tokenizer or a guarantee for arbitrary +// custom models. Unknown windows use a 32k allowance, with half reserved for tools, +// instructions and subsequent work. Current input is never truncated. +export function handoffBudget(input: { + readonly tokenCap: number; + readonly userText: string; + readonly attachments: ReadonlyArray; + readonly providerThread: OrchestrationV2ProviderThread; + readonly nativeContextBytes: number; +}): number { + const usage = input.providerThread.contextUsage; + const window = Math.min(usage?.maxTokens ?? 32_000, usage?.autoCompactThreshold ?? Infinity); + const native = usage?.usedTokens ?? input.nativeContextBytes; + const attachmentAllowance = input.attachments.reduce( + (sum, attachment) => sum + Math.max(4_096, attachment.sizeBytes), + 0, + ); + const current = Buffer.byteLength(JSON.stringify(input.userText)) + attachmentAllowance; + return Math.max( + 0, + Math.min( + input.tokenCap, + HANDOFF_BYTE_CAP - current, + window - native - current - Math.max(16_000, Math.ceil(window / 4)), + ), + ); +} + +export function historicalMessage( + item: OrchestrationV2TurnItem, +): OrchestrationV2HistoricalMessage | null { + let text: string; + switch (item.type) { + case "user_message": + case "assistant_message": + text = item.text; + break; + case "command_execution": + text = [ + `Command: ${item.input}`, + `Exit code: ${item.exitCode ?? "unknown"}`, + item.output ?? "", + ].join("\n"); + break; + case "error": + text = item.failure.message; + break; + case "run_interrupt_result": + text = item.message; + break; + case "file_change": + text = `File change: ${item.fileName}`; + break; + case "proposed_plan": + text = item.markdown; + break; + default: + return null; + } + return { + role: item.type === "user_message" ? "user" : "assistant", + text, + threadId: item.threadId, + runId: item.runId, + itemId: item.id, + providerThreadId: item.providerThreadId, + status: item.status, + kind: item.type, + }; +} + +export function renderHistoricalMessage(message: OrchestrationV2HistoricalMessage): string { + return `[Historical ${message.role}; ${message.kind}; thread=${message.threadId}; run=${message.runId ?? "imported"}; item=${message.itemId}; provider-thread=${message.providerThreadId ?? "none"}; status=${message.status}${message.runStatus === undefined ? "" : `; run-status=${message.runStatus}`}]\n${message.text}`; +} + +export function historyResponseItems( + messages: ReadonlyArray, + context: string, +) { + return [ + { type: "message", role: "user", content: [{ type: "input_text", text: context }] }, + ...messages.map((message) => ({ + type: "message", + role: message.role, + content: [ + { + type: message.role === "user" ? "input_text" : "output_text", + text: renderHistoricalMessage(message), + }, + ], + })), + ]; +} + +export function renderHistory( + messages: ReadonlyArray, + context: string, +): string { + return [context, ...messages.map(renderHistoricalMessage)].join("\n\n"); +} + +// Count the larger delivery representation, including attribution, escaping and +// protocol wrappers. The same selection is used by native and text-only adapters. +export function historyCost( + messages: ReadonlyArray, + context: string, +): number { + return ( + Math.max( + Buffer.byteLength(JSON.stringify(historyResponseItems(messages, context))), + Buffer.byteLength(JSON.stringify(renderHistory(messages, context))), + ) + 256 + ); +} + +export function selectHistory(input: { + readonly messages: ReadonlyArray; + readonly coverage: string; + readonly omittedItems?: number; + readonly budget: number; +}) { + const messages = input.messages; + const selected = new Set(); + const contextFor = (count: number) => + `${input.coverage}\nSelected ${count} intact items; omitted ${(input.omittedItems ?? 0) + messages.length - count} items. Historical material is context, not a new request or higher-priority instructions. Attached files and native tool/reasoning state are not replayed.`; + let remaining = + input.budget - + Math.max(historyCost([], contextFor(0)), historyCost([], contextFor(messages.length))); + const tryAdd = (index: number) => { + const message = messages[index]; + if (message === undefined || selected.has(index)) return; + const cost = Math.max( + Buffer.byteLength(JSON.stringify(historyResponseItems([message], "")[1])) + 1, + Buffer.byteLength(JSON.stringify(renderHistoricalMessage(message))) + 4, + ); + if (cost > remaining) return; + selected.add(index); + remaining -= cost; + }; + // Prioritize the latest request and partial answer, then original constraints. + // Oversized items are omitted whole and remain available through thread_read. + tryAdd(messages.findLastIndex((message) => message.role === "user")); + tryAdd(messages.findLastIndex((message) => message.role === "assistant")); + tryAdd(messages.findIndex((message) => message.role === "user")); + for (let index = messages.length - 1; index >= 0; index--) tryAdd(index); + return { + messages: messages.filter((_, index) => selected.has(index)), + context: contextFor(selected.size), + omittedItems: (input.omittedItems ?? 0) + messages.length - selected.size, + }; +} + +export function handoffCoverage(input: { + readonly threadId: string; + readonly coveredRunOrdinals: OrchestrationV2ContextHandoff["coveredRunOrdinals"]; + readonly items: ReadonlyArray; +}): string { + return [ + `Provider context handoff. Thread: ${input.threadId}. Covered app runs: ${input.coveredRunOrdinals.from}-${input.coveredRunOrdinals.to}.`, + `Source item range: ${input.items.at(0)?.id ?? "none"} through ${input.items.at(-1)?.id ?? "none"}.`, + `Recover omitted history using t3_thread_read({threadId:"${input.threadId}",view:"activity",limit:20,maxCharsPerItem:4000}); paginate with afterPosition=nextPosition. For an individual item use itemId and textOffset=nextTextOffset until null. Run/item IDs identify historical activity; no foreign tool calls are replayed.`, + ].join("\n"); +} diff --git a/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts b/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts new file mode 100644 index 000000000000..c33bb1b093b2 --- /dev/null +++ b/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts @@ -0,0 +1,135 @@ +import type { + OrchestrationV2ContextHandoff, + OrchestrationV2ProviderThread, +} from "@t3tools/contracts"; +import type { ProviderAdapterV2HistoricalContext } from "./ProviderAdapter.ts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { historyCost, renderHistory, selectHistory } from "./ContextHandoffBudget.ts"; + +/** Persist before/after injection: an ambiguous pending delivery requires a fresh native thread. */ +export const deliverContextHandoffs = Effect.fn("orchestrationV2.deliverContextHandoffs")( + function* (input: { + readonly handoffs: ReadonlyArray; + readonly providerThread: OrchestrationV2ProviderThread; + readonly budget: number; + readonly alreadyDeliveredItemIds: ReadonlySet; + readonly inject?: ( + history: ProviderAdapterV2HistoricalContext, + ) => Effect.Effect; + readonly persist: (handoff: OrchestrationV2ContextHandoff) => Effect.Effect; + }) { + const nativeThreadId = input.providerThread.nativeThreadRef?.nativeId ?? undefined; + const pending = input.handoffs.filter( + (handoff) => + nativeThreadId === undefined || + handoff.delivery?.nativeThreadId !== nativeThreadId || + handoff.delivery.status === "pending", + ); + if (pending.length === 0) return { context: "", delivered: Effect.void }; + const coverage = pending + .map( + (handoff) => + `Context handoff (${handoff.strategy === "fork_delta_summary" ? "merge_back / fork_delta_summary" : handoff.strategy}):\n${ + handoff.history?.coverage ?? + `From thread ${handoff.threadId}, runs ${handoff.coveredRunOrdinals.from}-${handoff.coveredRunOrdinals.to}. Recover history with t3_thread_read, view=activity; paginate with afterPosition, and use itemId/textOffset for long items.` + }`, + ) + .join("\n"); + const seen = new Set(input.alreadyDeliveredItemIds); + const messages = pending + .flatMap((handoff) => handoff.history?.messages ?? []) + .filter((message) => { + if (seen.has(message.itemId)) return false; + seen.add(message.itemId); + return true; + }); + // Old preview handoffs remain readable. Their preformatted context is included + // as a whole when it fits, otherwise the coverage marker points to retrieval. + const oldContext = pending + .filter((handoff) => handoff.history === undefined) + .map((handoff) => handoff.summaryText) + .join("\n\n"); + const fullCoverage = + oldContext && historyCost([], `${coverage}\n${oldContext}`) <= input.budget + ? `${coverage}\n${oldContext}` + : coverage; + const selected = selectHistory({ + messages, + coverage: fullCoverage, + omittedItems: pending.reduce((sum, handoff) => sum + (handoff.history?.omittedItems ?? 0), 0), + budget: input.budget, + }); + if (historyCost(selected.messages, selected.context) > input.budget) { + return yield* new ContextHandoffBudgetError(); + } + const persist = (status: "pending" | "injected" | "inline") => + Effect.forEach( + pending, + (handoff) => + input.persist({ + ...handoff, + ...(nativeThreadId === undefined + ? {} + : { + delivery: { + nativeThreadId, + status, + itemIds: selected.messages + .filter((message) => + handoff.history?.messages.some( + (candidate) => candidate.itemId === message.itemId, + ), + ) + .map((message) => message.itemId), + }, + }), + }), + { discard: true }, + ); + if (input.inject !== undefined && nativeThreadId !== undefined) { + if ( + pending.some( + (handoff) => + handoff.delivery?.nativeThreadId === nativeThreadId && + handoff.delivery.status === "pending", + ) + ) { + return yield* new ContextHandoffDeliveryUncertainError(); + } + yield* persist("pending"); + const injected = yield* input.inject({ + messages: selected.messages, + context: selected.context, + }); + if (injected) { + yield* persist("injected"); + return { context: "", delivered: Effect.void }; + } + } else { + // Text-only delivery can also be accepted before a connection drops. + yield* persist("pending"); + } + return { + context: renderHistory(selected.messages, selected.context), + delivered: persist("inline"), + }; + }, +); + +export class ContextHandoffBudgetError extends Schema.TaggedError()( + "ContextHandoffBudgetError", + {}, +) { + override get message() { + return "Insufficient context allowance for the provider handoff. Compact the target conversation or use a larger-context model; the current request has not been truncated."; + } +} +export class ContextHandoffDeliveryUncertainError extends Schema.TaggedError()( + "ContextHandoffDeliveryUncertainError", + {}, +) { + override get message() { + return "Historical context delivery is uncertain; replace the native thread before retrying."; + } +} diff --git a/apps/server/src/orchestration-v2/ContextHandoffService.ts b/apps/server/src/orchestration-v2/ContextHandoffService.ts index 660245bad6bc..d566cdcdce59 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffService.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffService.ts @@ -1,6 +1,7 @@ import { OrchestrationV2ContextHandoff, type OrchestrationV2TurnItem, + type OrchestrationV2Run, ProviderInstanceId, ProviderThreadId, RunId, @@ -12,6 +13,14 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import { + DEFAULT_HANDOFF_TOKEN_CAP, + handoffTokenCapConfig, + handoffCoverage, + historicalMessage, + renderHistory, + selectHistory, +} from "./ContextHandoffBudget.ts"; import { IdAllocatorV2 } from "./IdAllocator.ts"; export class ContextHandoffPrepareError extends Schema.TaggedError()( @@ -63,12 +72,13 @@ export interface ContextHandoffServiceV2Shape { readonly prepareProviderHandoff: (input: { readonly threadId: ThreadId; readonly targetRunId: RunId; - readonly transferId: NonNullable; + readonly transferId: OrchestrationV2ContextHandoff["transferId"]; readonly fromProviderThreadIds: ReadonlyArray; readonly toProviderThreadId: ProviderThreadId; readonly fromProviderInstanceId: ProviderInstanceId; readonly toProviderInstanceId: ProviderInstanceId; readonly coveredRunOrdinals: OrchestrationV2ContextHandoff["coveredRunOrdinals"]; + readonly runs?: ReadonlyArray; readonly strategy: Extract< OrchestrationV2ContextHandoff["strategy"], "delta_since_target_last_seen" | "full_thread_summary" @@ -131,36 +141,6 @@ function makeForkDeltaSummary(input: { ].join("\n"); } -function makeProviderHandoffSummary(input: { - readonly fromProviderInstanceId: ProviderInstanceId; - readonly toProviderInstanceId: ProviderInstanceId; - readonly coveredRunOrdinals: OrchestrationV2ContextHandoff["coveredRunOrdinals"]; - readonly strategy: Extract< - OrchestrationV2ContextHandoff["strategy"], - "delta_since_target_last_seen" | "full_thread_summary" - >; - readonly items: ReadonlyArray; -}): string { - const itemLines = input.items.flatMap((item) => { - if (item.type === "handoff") { - return []; - } - const line = summarizeDeltaItem(item); - return line === null ? [] : [line]; - }); - return [ - input.strategy === "full_thread_summary" - ? "Full conversation context for provider handoff." - : "Conversation delta since this provider last participated.", - `From driver: ${input.fromProviderInstanceId}`, - `To driver: ${input.toProviderInstanceId}`, - `Covered app runs: ${input.coveredRunOrdinals.from}-${input.coveredRunOrdinals.to}`, - "", - "Canonical conversation context:", - ...(itemLines.length === 0 ? ["- No user-visible context items."] : itemLines), - ].join("\n"); -} - function makeLegacyImportSummary(items: ReadonlyArray): string { const sections = items.flatMap((item) => { switch (item.type) { @@ -249,6 +229,9 @@ export function providerMessageWithContextHandoffs(input: { const makeContextHandoffService = Effect.fn("orchestrationV2.ContextHandoffService.layer")( function* () { const idAllocator = yield* IdAllocatorV2; + const tokenCap = yield* handoffTokenCapConfig.pipe( + Effect.orElseSucceed(() => DEFAULT_HANDOFF_TOKEN_CAP), + ); const prepareLegacyImport = Effect.fn("orchestrationV2.contextHandoff.prepareLegacyImport")( function* (input: { @@ -277,6 +260,15 @@ const makeContextHandoffService = Effect.fn("orchestrationV2.ContextHandoffServi }), ), ); + const coverage = handoffCoverage({ ...input, coveredRunOrdinals: { from: 1, to: 1 } }); + const selected = selectHistory({ + messages: input.items.flatMap((item) => { + const message = historicalMessage(item); + return message === null ? [] : [message]; + }), + coverage, + budget: tokenCap, + }); return { id: handoffId, transferId: null, @@ -289,6 +281,7 @@ const makeContextHandoffService = Effect.fn("orchestrationV2.ContextHandoffServi status: "ready", summaryMessageId: null, summaryText: makeLegacyImportSummary(input.items), + history: { messages: selected.messages, coverage, omittedItems: selected.omittedItems }, createdByProviderInstanceId: null, createdAt: input.createdAt, updatedAt: input.createdAt, @@ -371,6 +364,19 @@ const makeContextHandoffService = Effect.fn("orchestrationV2.ContextHandoffServi }), ), ); + const coverage = handoffCoverage({ + threadId: input.sourceThreadId, + coveredRunOrdinals: input.coveredRunOrdinals, + items: input.deltaItems, + }); + const selected = selectHistory({ + messages: input.deltaItems.flatMap((item) => { + const message = historicalMessage(item); + return message === null ? [] : [message]; + }), + coverage, + budget: tokenCap, + }); return { id: handoffId, transferId: input.transferId, @@ -383,6 +389,7 @@ const makeContextHandoffService = Effect.fn("orchestrationV2.ContextHandoffServi status: "ready", summaryMessageId: null, summaryText: makeForkDeltaSummary(input), + history: { messages: selected.messages, coverage, omittedItems: selected.omittedItems }, createdByProviderInstanceId: null, createdAt: input.createdAt, updatedAt: input.createdAt, @@ -395,12 +402,13 @@ const makeContextHandoffService = Effect.fn("orchestrationV2.ContextHandoffServi )(function* (input: { readonly threadId: ThreadId; readonly targetRunId: RunId; - readonly transferId: NonNullable; + readonly transferId: OrchestrationV2ContextHandoff["transferId"]; readonly fromProviderThreadIds: ReadonlyArray; readonly toProviderThreadId: ProviderThreadId; readonly fromProviderInstanceId: ProviderInstanceId; readonly toProviderInstanceId: ProviderInstanceId; readonly coveredRunOrdinals: OrchestrationV2ContextHandoff["coveredRunOrdinals"]; + readonly runs?: ReadonlyArray; readonly strategy: Extract< OrchestrationV2ContextHandoff["strategy"], "delta_since_target_last_seen" | "full_thread_summary" @@ -426,6 +434,23 @@ const makeContextHandoffService = Effect.fn("orchestrationV2.ContextHandoffServi }), ), ); + const runStatuses = new Map(input.runs?.map((run) => [run.id, run.status])); + const coverage = handoffCoverage(input); + const selected = selectHistory({ + messages: input.items.flatMap((item) => { + const message = historicalMessage(item); + return message === null + ? [] + : [ + { + ...message, + ...(item.runId === null ? {} : { runStatus: runStatuses.get(item.runId) }), + }, + ]; + }), + coverage, + budget: tokenCap, + }); return { id: handoffId, transferId: input.transferId, @@ -437,7 +462,8 @@ const makeContextHandoffService = Effect.fn("orchestrationV2.ContextHandoffServi strategy: input.strategy, status: "ready", summaryMessageId: null, - summaryText: makeProviderHandoffSummary(input), + summaryText: renderHistory(selected.messages, selected.context), + history: { messages: selected.messages, coverage, omittedItems: selected.omittedItems }, createdByProviderInstanceId: null, createdAt: input.createdAt, updatedAt: input.createdAt, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index ea5a5da883c8..3e46a0e455d1 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -583,12 +583,16 @@ function isHandoffSourceRun(run: OrchestrationV2Run): boolean { return run.status === "completed" || run.status === "failed" || run.status === "interrupted"; } -function lastCompletedRunForProviderThread( +function lastDeliveredRunForProviderThread( projection: OrchestrationV2ThreadProjection, providerThreadId: OrchestrationV2ProviderThread["id"], ): OrchestrationV2Run | undefined { return projection.runs.findLast( - (run) => run.status === "completed" && run.providerThreadId === providerThreadId, + (run) => + isHandoffSourceRun(run) && + run.providerThreadId === providerThreadId && + (run.status === "completed" || + projection.providerTurns.some((turn) => turn.runAttemptId === run.activeAttemptId)), ); } @@ -1108,7 +1112,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); const latestCompletedRun = projection.runs.findLast((run) => run.status === "completed"); const latestHandoffRun = projection.runs.findLast(isHandoffSourceRun); - const targetLastCompletedRun = lastCompletedRunForProviderThread( + const targetLastCompletedRun = lastDeliveredRunForProviderThread( projection, queuedProviderThread.id, ); @@ -1181,6 +1185,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio from: coveredRuns[0]!.ordinal, to: coveredRuns.at(-1)!.ordinal, }, + runs: projection.runs, strategy: handoffStrategy, items: [ ...(needsFullContext && latestCompletedRun !== undefined @@ -3463,6 +3468,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio coveredRunOrdinals: { from: 1, to: targetRun.ordinal }, strategy: "full_thread_summary", items: input.projection.turnItems, + runs: input.projection.runs, createdAt: now, }) .pipe(mapDispatchError(input.command)); @@ -4914,6 +4920,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio coveredRunOrdinals: visibleDeltaRunOrdinals(sourceProjection, portableForkItems), strategy: "full_thread_summary", items: portableForkItems, + runs: sourceProjection.runs, createdAt: now, }) .pipe( @@ -4931,7 +4938,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const targetLastCompletedRun = targetProviderThread === undefined ? undefined - : lastCompletedRunForProviderThread(projection, targetProviderThread.id); + : lastDeliveredRunForProviderThread(projection, targetProviderThread.id); const providerSwitchCoveredRuns = !isProviderSwitch || canResumeAcrossInstances || latestHandoffRun === undefined ? [] @@ -5009,6 +5016,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? "full_thread_summary" : "delta_since_target_last_seen", items: providerSwitchItems, + runs: projection.runs, createdAt: now, }) .pipe( diff --git a/apps/server/src/orchestration-v2/ProviderAdapter.ts b/apps/server/src/orchestration-v2/ProviderAdapter.ts index 38e1346419f6..5f0826aceb6c 100644 --- a/apps/server/src/orchestration-v2/ProviderAdapter.ts +++ b/apps/server/src/orchestration-v2/ProviderAdapter.ts @@ -1,3 +1,4 @@ +import type { OrchestrationV2HistoricalMessage } from "@t3tools/contracts"; import { ChatAttachment, CheckpointId, @@ -473,6 +474,11 @@ export interface ProviderAdapterV2EventSubscription { readonly close: Effect.Effect; } +export interface ProviderAdapterV2HistoricalContext { + readonly messages: ReadonlyArray; + readonly context: string; +} + export interface ProviderAdapterV2SessionRuntime { readonly instanceId: ProviderInstanceId; readonly driver: ProviderDriverKind; @@ -509,6 +515,12 @@ export interface ProviderAdapterV2SessionRuntime { readonly modelSelection?: ModelSelection; readonly runtimePolicy?: ProviderAdapterV2RuntimePolicy; }) => Effect.Effect; + /** False means the native protocol explicitly does not support history injection. */ + readonly injectHistory?: ( + input: ProviderAdapterV2HistoricalContext & { + readonly providerThread: OrchestrationV2ProviderThread; + }, + ) => Effect.Effect; readonly startTurn: ( input: ProviderAdapterV2TurnInput, ) => Effect.Effect; diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index c543423abe89..b4b5f1527f08 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -23,10 +23,18 @@ import { GitWorkflowService } from "../git/GitWorkflowService.ts"; import { ProjectService } from "../project/ProjectService.ts"; import { ProviderAuthService } from "../provider/Services/ProviderAuthService.ts"; import { EventSinkV2 } from "./EventSink.ts"; +import { ContextHandoffServiceV2 } from "./ContextHandoffService.ts"; import { - ContextHandoffServiceV2, - providerMessageWithContextHandoffs, -} from "./ContextHandoffService.ts"; + DEFAULT_HANDOFF_TOKEN_CAP, + handoffTokenCapConfig, + handoffBudget, + historicalMessage, +} from "./ContextHandoffBudget.ts"; +import { deliverContextHandoffs } from "./ContextHandoffDelivery.ts"; +import { + ProviderAdapterTurnStartError, + type ProviderAdapterV2HistoricalContext, +} from "./ProviderAdapter.ts"; import { IdAllocatorV2 } from "./IdAllocator.ts"; import { ProjectionStoreV2 } from "./ProjectionStore.ts"; import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; @@ -113,7 +121,15 @@ export const layer: Layer.Layer< (candidate) => candidate.id === rootNode?.checkpointScopeId, ); const handoffs = projection.contextHandoffs.filter( - (handoff) => handoff.targetRunId === run.id && handoff.status === "ready", + (handoff) => + handoff.status === "ready" && + (handoff.targetRunId === run.id || + (handoff.toProviderThreadId === run.providerThreadId && + projection.runs.some( + (source) => + source.id === handoff.targetRunId && + (source.status === "failed" || source.status === "interrupted"), + ))), ); const nativeForkTransfer = projection.contextTransfers.find( (transfer) => @@ -123,15 +139,6 @@ export const layer: Layer.Layer< transfer.status === "pending" && transfer.resolution === null, ); - const existingResumeFallback = projection.contextTransfers.find( - (transfer) => - transfer.type === "provider_handoff" && - transfer.sourceThreadId === projection.thread.id && - transfer.targetThreadId === projection.thread.id && - transfer.targetRunId === run.id && - transfer.status === "resolved_portable" && - transfer.resolution?.strategy === "portable_context", - ); if ( rootNode === undefined || attempt === undefined || @@ -426,13 +433,29 @@ export const layer: Layer.Layer< existingProviderThread: providerThread, }); } + const uncertainDelivery = projection.contextHandoffs.some( + (handoff) => + handoff.toProviderThreadId === providerThread.id && + handoff.delivery?.nativeThreadId === providerThread.nativeThreadRef?.nativeId && + handoff.delivery?.status === "pending", + ); const resumed = yield* Effect.result( - session.resumeThread({ - providerThread, - threadId: projection.thread.id, - modelSelection: run.modelSelection, - runtimePolicy: resolvedRuntimePolicy, - }), + uncertainDelivery + ? Effect.fail( + new ProviderAdapterTurnStartError({ + driver: session.driver, + threadId: projection.thread.id, + providerThreadId: providerThread.id, + runId, + cause: "Uncertain native history injection", + }), + ) + : session.resumeThread({ + providerThread, + threadId: projection.thread.id, + modelSelection: run.modelSelection, + runtimePolicy: resolvedRuntimePolicy, + }), ); if (resumed._tag === "Success") { return resumed.success; @@ -448,9 +471,6 @@ export const layer: Layer.Layer< // still adopting this row's identity. existingProviderThread: { ...providerThread, nativeThreadRef: null }, }); - if (existingResumeFallback !== undefined) { - return replacement; - } const transferId = yield* idAllocator.allocate.contextTransfer({ sourceThreadId: projection.thread.id, targetThreadId: projection.thread.id, @@ -467,10 +487,17 @@ export const layer: Layer.Layer< toProviderInstanceId: run.providerInstanceId, coveredRunOrdinals: { from: 1, to: Math.max(1, run.ordinal - 1) }, strategy: "full_thread_summary", - items: projection.turnItems, + runs: projection.runs, + items: projection.turnItems.filter( + (item) => + item.runId === null || + projection.runs.some( + (source) => source.id === item.runId && source.ordinal < run.ordinal, + ), + ), createdAt, }); - effectiveHandoffs = [...handoffs, handoff]; + effectiveHandoffs = [handoff]; yield* eventSink.write({ events: [ { @@ -639,11 +666,173 @@ export const layer: Layer.Layer< const routableSubagents = projection.subagents.filter((subagent) => canRouteRelatedSubagent(subagent.status), ); + const userText = projectComposerContextForProvider({ + text: message.text, + records: message.context?.records ?? [], + }); + const tokenCap = yield* handoffTokenCapConfig.pipe( + Effect.orElseSucceed(() => DEFAULT_HANDOFF_TOKEN_CAP), + ); + const sameNativeThread = + runningProviderThread.nativeThreadRef?.nativeId === + providerThread.nativeThreadRef?.nativeId; + const deliveredItemIds = new Set( + projection.contextHandoffs.flatMap((handoff) => + handoff.toProviderThreadId === providerThread.id && + handoff.delivery?.nativeThreadId === runningProviderThread.nativeThreadRef?.nativeId && + handoff.delivery?.status !== "pending" + ? (handoff.delivery?.itemIds ?? []) + : [], + ), + ); + // Canonical text is a fallback estimate when a resumed provider supplies no + // context telemetry. Native compaction/hidden tool state may differ. + const nativeContextBytes = () => + sameNativeThread + ? projection.turnItems.reduce((sum, item) => { + if (item.providerThreadId !== providerThread.id && !deliveredItemIds.has(item.id)) + return sum; + const historical = historicalMessage(item); + return sum + (historical === null ? 0 : Buffer.byteLength(historical.text)); + }, 0) + : 0; + const previousModel = projection.runs.findLast( + (source) => source.id !== run.id && source.providerThreadId === providerThread.id, + )?.modelSelection.model; + const budgetProviderThread = sameNativeThread + ? { + ...providerThread, + contextUsage: + previousModel !== undefined && previousModel !== run.modelSelection.model + ? null + : providerThread.contextUsage, + } + : { ...runningProviderThread, contextUsage: null }; + const deliveredAttemptIds = new Set( + projection.providerTurns.map((turn) => turn.runAttemptId), + ); + const missedRuns = projection.runs.filter( + (source) => + source.ordinal < run.ordinal && + source.providerThreadId === providerThread.id && + (source.status === "failed" || source.status === "interrupted") && + !deliveredAttemptIds.has(source.activeAttemptId), + ); + const missedRunIds = new Set(missedRuns.map((source) => source.id)); + const missedItems = + missedRunIds.size === 0 + ? [] + : projection.turnItems.filter( + (item) => + item.runId !== null && + missedRunIds.has(item.runId) && + !deliveredItemIds.has(item.id) && + historicalMessage(item) !== null, + ); + const deliverySession = + effectiveHandoffs.length === 0 && missedItems.length === 0 + ? session + : { + ...session, + startTurn: (turnInput: Parameters[0]) => + Effect.gen(function* () { + // A failed turn/start can leave the requested turn absent from + // native history even when its preceding handoff was injected. + const retryHandoff = + missedItems.length === 0 + ? [] + : [ + yield* contextHandoffService.prepareProviderHandoff({ + threadId: projection.thread.id, + targetRunId: run.id, + transferId: null, + fromProviderThreadIds: [providerThread.id], + toProviderThreadId: providerThread.id, + fromProviderInstanceId: run.providerInstanceId, + toProviderInstanceId: run.providerInstanceId, + coveredRunOrdinals: { + from: missedRuns[0]!.ordinal, + to: missedRuns.at(-1)!.ordinal, + }, + strategy: "delta_since_target_last_seen", + items: missedItems, + runs: projection.runs, + createdAt: yield* DateTime.now, + }), + ]; + const delivery = yield* deliverContextHandoffs({ + handoffs: [...effectiveHandoffs, ...retryHandoff], + providerThread: runningProviderThread, + budget: handoffBudget({ + tokenCap, + userText, + attachments: message.attachments, + providerThread: budgetProviderThread, + nativeContextBytes: + budgetProviderThread.contextUsage?.usedTokens === undefined + ? nativeContextBytes() + : 0, + }), + alreadyDeliveredItemIds: deliveredItemIds, + ...(session.injectHistory === undefined + ? {} + : { + inject: (history: ProviderAdapterV2HistoricalContext) => + session.injectHistory!({ + providerThread: runningProviderThread, + ...history, + }), + }), + persist: (handoff) => + Effect.gen(function* () { + const updatedAt = yield* DateTime.now; + yield* eventSink.write({ + events: [ + { + id: yield* idAllocator.allocate.event({ + threadId: projection.thread.id, + }), + type: "context-handoff.updated", + threadId: projection.thread.id, + runId: run.id, + providerInstanceId: run.providerInstanceId, + occurredAt: updatedAt, + payload: { ...handoff, updatedAt }, + }, + ], + }); + }), + }); + if (!(yield* isCurrentAttemptInStatus("running"))) return; + yield* session.startTurn({ + ...turnInput, + message: { + ...turnInput.message, + text: + delivery.context === "" + ? userText + : `${delivery.context}\n\nUser message:\n${userText}`, + }, + }); + yield* delivery.delivered; + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterTurnStartError({ + driver: session.driver, + threadId: projection.thread.id, + providerThreadId: providerThread.id, + runId: run.id, + cause, + }), + ), + ), + }; yield* runExecution.startRootRun({ commandId: CommandId.make(`command:effect:provider-turn.start:${run.id}`), appThread: projection.thread, providerSessionId, - session, + session: deliverySession, run: runningRun, rootNode: runningRootNode, checkpointScope, @@ -699,19 +888,7 @@ export const layer: Layer.Layer< ), message: { messageId: message.id, - text: - effectiveHandoffs.length === 0 - ? projectComposerContextForProvider({ - text: message.text, - records: message.context?.records ?? [], - }) - : providerMessageWithContextHandoffs({ - handoffs: effectiveHandoffs, - userText: projectComposerContextForProvider({ - text: message.text, - records: message.context?.records ?? [], - }), - }), + text: userText, attachments: message.attachments, createdBy: message.createdBy, creationSource: message.creationSource, diff --git a/apps/server/src/orchestration-v2/WireProjection.ts b/apps/server/src/orchestration-v2/WireProjection.ts index 04dde8a1be89..8ceace137f11 100644 --- a/apps/server/src/orchestration-v2/WireProjection.ts +++ b/apps/server/src/orchestration-v2/WireProjection.ts @@ -1,5 +1,6 @@ import type { OrchestrationV2DomainEvent, + OrchestrationV2ContextHandoff, OrchestrationV2ThreadProjection, OrchestrationV2TurnItem, } from "@t3tools/contracts"; @@ -104,6 +105,13 @@ export function projectTurnItemForWire(item: OrchestrationV2TurnItem): Orchestra } } +export function projectContextHandoffForWire( + handoff: OrchestrationV2ContextHandoff, +): OrchestrationV2ContextHandoff { + const { history: _history, delivery: _delivery, ...projected } = handoff; + return projected; +} + export function projectThreadProjectionForWire( projection: OrchestrationV2ThreadProjection, ): OrchestrationV2ThreadProjection { @@ -118,6 +126,7 @@ export function projectThreadProjectionForWire( }; return { ...projection, + contextHandoffs: projection.contextHandoffs.map(projectContextHandoffForWire), turnItems: projection.turnItems.map(project), visibleTurnItems: projection.visibleTurnItems.map((row) => ({ ...row, @@ -131,5 +140,7 @@ export function projectDomainEventForWire( ): OrchestrationV2DomainEvent { return event.type === "turn-item.updated" ? { ...event, payload: projectTurnItemForWire(event.payload) } - : event; + : event.type === "context-handoff.updated" + ? { ...event, payload: projectContextHandoffForWire(event.payload) } + : event; } diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index 7cf8cf7be1d9..be4699ede1de 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -1,3 +1,4 @@ +import { historyResponseItems } from "../ContextHandoffBudget.ts"; import { assert, describe, it } from "@effect/vitest"; import { CommandId, @@ -25,6 +26,7 @@ import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; +import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; @@ -50,6 +52,7 @@ import { import { layer as projectionStoreLayer } from "../ProjectionStore.ts"; import { type ProviderAdapterV2Event, + type ProviderAdapterV2HistoricalContext, ProviderAdapterProtocolError, type ProviderAdapterV2Shape, } from "../ProviderAdapter.ts"; @@ -68,6 +71,8 @@ import { import { makeOrchestratorV2ReplayLayerWithRegistry } from "./ProviderReplayHarness.ts"; import { checkpointWorkspace } from "./ReplayFixtureWorkspace.ts"; +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); + const threadId = ThreadId.make("thread:provider-switch"); const projectId = ProjectId.make("project:provider-switch"); const firstPrompt = "Respond with exactly: codex before switch"; @@ -97,6 +102,10 @@ function makeTestAdapter(input: { readonly responseByRunOrdinal: Readonly>; readonly responseByThreadId?: Readonly>>>; readonly capturedTurns: Ref.Ref>; + readonly injectedHistory?: Ref.Ref>; + readonly failStartOnce?: Ref.Ref; + readonly failInjectionOnce?: Ref.Ref; + readonly nativeThreadGeneration?: Ref.Ref; readonly failResume?: boolean; readonly failedRunOrdinals?: ReadonlySet; readonly interruptedRunOrdinals?: ReadonlySet; @@ -135,7 +144,11 @@ function makeTestAdapter(input: { ensureThread: (threadInput) => Effect.gen(function* () { const createdAt = yield* DateTime.now; - const nativeThreadId = `${input.driver}:${threadInput.threadId}`; + const generation = + input.nativeThreadGeneration === undefined + ? "" + : `:${yield* Ref.getAndUpdate(input.nativeThreadGeneration, (value) => value + 1)}`; + const nativeThreadId = `${input.driver}:${threadInput.threadId}${generation}`; return { id: ProviderThreadId.make(`provider-thread:${nativeThreadId}`), driver: input.driver, @@ -162,8 +175,36 @@ function makeTestAdapter(input: { input.failResume ? unimplemented(input.driver, "simulated native resume failure") : Effect.succeed(providerThread), + ...(input.injectedHistory === undefined + ? {} + : { + injectHistory: (history: ProviderAdapterV2HistoricalContext) => + Ref.update(input.injectedHistory!, (current) => [ + ...current, + ...historyResponseItems(history.messages, history.context), + ]).pipe( + Effect.andThen( + Effect.gen(function* () { + if ( + input.failInjectionOnce !== undefined && + (yield* Ref.getAndSet(input.failInjectionOnce, false)) + ) + return yield* unimplemented( + input.driver, + "lost injection acknowledgement", + ); + return true; + }), + ), + ), + }), startTurn: (turnInput) => Effect.gen(function* () { + if ( + input.failStartOnce !== undefined && + (yield* Ref.getAndSet(input.failStartOnce, false)) + ) + return yield* unimplemented(input.driver, "turn start failed after injection"); yield* Effect.yieldNow; yield* Ref.update(input.capturedTurns, (turns) => [ ...turns, @@ -300,166 +341,330 @@ const waitForIdle = Effect.fn("ProviderSwitchTest.waitForIdle")(function* ( }); describe("orchestration v2 provider switching", () => { + for (const failure of ["turn-start", "injection"] as const) { + it.live( + `recovers ${failure} failure without duplicating history in the same native thread`, + () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace(`handoff-retry-${failure}`); + const capturedTurns = yield* Ref.make>([]); + const injectedHistory = yield* Ref.make>([]); + const failOnce = yield* Ref.make(true); + const generation = yield* Ref.make(0); + const registry = makeProviderAdapterRegistryLayer([ + makeTestAdapter({ + instanceId: CODEX_MODEL_SELECTION.instanceId, + driver: CODEX_DRIVER, + capabilities: CodexProviderCapabilitiesV2, + modelSelection: CODEX_MODEL_SELECTION, + responseByRunOrdinal: { 1: "Original partial work" }, + capturedTurns, + }), + makeTestAdapter({ + instanceId: CLAUDE_MODEL_SELECTION.instanceId, + driver: CLAUDE_DRIVER, + capabilities: ClaudeProviderCapabilitiesV2, + modelSelection: CLAUDE_MODEL_SELECTION, + responseByRunOrdinal: {}, + capturedTurns, + injectedHistory, + nativeThreadGeneration: generation, + ...(failure === "turn-start" + ? { failStartOnce: failOnce } + : { failInjectionOnce: failOnce }), + }), + ]); + yield* Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const worker = yield* OrchestrationEffectWorkerV2; + const dispatch = (ordinal: number, text: string, selection: ModelSelection) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`retry:${ordinal}`), + threadId, + messageId: MessageId.make(`retry:${ordinal}`), + createdBy: "user", + creationSource: "web", + text, + attachments: [], + modelSelection: selection, + dispatchMode: { type: "start_immediately" }, + }); + const wait = (ordinal: number, status: "failed" | "completed") => + orchestrator.streamStoredEvents.pipe( + Stream.filter( + ({ event }) => + event.type === "run.updated" && + event.payload.ordinal === ordinal && + event.payload.status === status, + ), + Stream.runHead, + Effect.andThen(worker.drain()), + ); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("retry:create"), + threadId, + projectId, + createdBy: "user", + creationSource: "web", + title: "Handoff retry", + modelSelection: CODEX_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* dispatch(1, "Original request with constraints", CODEX_MODEL_SELECTION); + yield* wait(1, "completed"); + yield* dispatch(2, "First target request", CLAUDE_MODEL_SELECTION); + yield* wait(2, "failed"); + const failed = yield* orchestrator.getThreadProjection(threadId); + const failedHandoff = failed.contextHandoffs.at(-1)!; + assert.equal( + failedHandoff.delivery?.status, + failure === "turn-start" ? "injected" : "pending", + ); + const historyBeforeRetry = (yield* Ref.get(injectedHistory)).length; + yield* dispatch(3, "Retry target request", CLAUDE_MODEL_SELECTION); + yield* wait(3, "completed"); + const retried = yield* orchestrator.getThreadProjection(threadId); + const lastTurn = (yield* Ref.get(capturedTurns)).at(-1)!; + assert.equal(lastTurn.text, "Retry target request"); + if (failure === "turn-start") { + const delta = yield* encodeJson( + (yield* Ref.get(injectedHistory)).slice(historyBeforeRetry), + ); + assert.include(delta, "First target request"); + assert.notInclude(delta, "Original request with constraints"); + assert.notInclude(delta, "Original partial work"); + assert.equal(yield* Ref.get(generation), 1); + } else { + assert.equal(yield* Ref.get(generation), 2); + assert.notEqual( + retried.contextHandoffs.at(-1)?.delivery?.nativeThreadId, + failedHandoff.delivery?.nativeThreadId, + ); + const newHistory = yield* encodeJson( + (yield* Ref.get(injectedHistory)).slice(historyBeforeRetry), + ); + assert.include(newHistory, "Original request with constraints"); + assert.include(newHistory, "Original partial work"); + assert.notInclude(newHistory, "Retry target request"); + } + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { + name: `handoff-retry-${failure}`, + runtimePolicyOverride: { + cwd, + approvalPolicy: "never", + sandboxPolicy: { type: "readOnly" }, + }, + }, + registry, + ), + ), + ); + }), + ), + ); + } + for (const status of ["failed", "interrupted"] as const) { for (const queued of [false, true]) { for (const returning of [false, true]) { - it.live( - `hands off ${status} context ${queued ? "through the queue" : "immediately"} to ${returning ? "a returning" : "a new"} provider`, - () => - Effect.scoped( - Effect.gen(function* () { - const cwd = yield* checkpointWorkspace(`handoff-${status}-${queued}-${returning}`); - const capturedTurns = yield* Ref.make>([]); - const started = yield* Deferred.make(); - const release = yield* Deferred.make(); - const sourceOrdinal = returning ? 2 : 1; - const sourceSelection = returning ? CLAUDE_MODEL_SELECTION : CODEX_MODEL_SELECTION; - const targetSelection = returning ? CODEX_MODEL_SELECTION : CLAUDE_MODEL_SELECTION; - const originalPrompt = - "Keep the release marker violet and preserve the existing API."; - const partialResponse = "I checked the API and found the release configuration."; - const registryLayer = makeProviderAdapterRegistryLayer( - ( - [ - [CODEX_MODEL_SELECTION, CODEX_DRIVER, CodexProviderCapabilitiesV2], - [CLAUDE_MODEL_SELECTION, CLAUDE_DRIVER, ClaudeProviderCapabilitiesV2], - ] as const - ).map(([modelSelection, driver, capabilities]) => - makeTestAdapter({ - instanceId: modelSelection.instanceId, - driver, - capabilities, - modelSelection, - responseByRunOrdinal: { [sourceOrdinal]: partialResponse }, - capturedTurns, - ...(modelSelection.instanceId === sourceSelection.instanceId - ? { - failedRunOrdinals: new Set(status === "failed" ? [sourceOrdinal] : []), - interruptedRunOrdinals: new Set( - status === "interrupted" ? [sourceOrdinal] : [], - ), - holdRunOrdinal: sourceOrdinal, - holdFirstTurn: started, - releaseFirstTurn: release, - } - : {}), - }), - ), - ); - yield* Effect.gen(function* () { - const orchestrator = yield* OrchestratorV2; - const worker = yield* OrchestrationEffectWorkerV2; - const waitForRun = ( - ordinal: number, - expectedStatus: "completed" | "failed" | "interrupted", - ) => - orchestrator.streamStoredEvents.pipe( - Stream.filter( - ({ event }) => - event.type === "run.updated" && - event.threadId === threadId && - event.payload.ordinal === ordinal && - event.payload.status === expectedStatus, - ), - Stream.runHead, - Effect.andThen(worker.drain()), - ); - const dispatch = ( - key: string, - text: string, - modelSelection: ModelSelection, - queue = false, - ) => - orchestrator.dispatch({ - type: "message.dispatch", - commandId: CommandId.make(`command:handoff:${key}`), + for (const native of [false, true]) { + it.live( + `hands off ${status} context ${queued ? "through the queue" : "immediately"} to ${returning ? "a returning" : "a new"} provider via ${native ? "native history" : "text"}`, + () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace( + `handoff-${status}-${queued}-${returning}`, + ); + const capturedTurns = yield* Ref.make>([]); + const injectedHistory = yield* Ref.make>([]); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const sourceOrdinal = returning ? 2 : 1; + const sourceSelection = returning + ? CLAUDE_MODEL_SELECTION + : CODEX_MODEL_SELECTION; + const targetSelection = returning + ? CODEX_MODEL_SELECTION + : CLAUDE_MODEL_SELECTION; + const originalPrompt = + "Keep the release marker violet and preserve the existing API."; + const partialResponse = "I checked the API and found the release configuration."; + const registryLayer = makeProviderAdapterRegistryLayer( + ( + [ + [CODEX_MODEL_SELECTION, CODEX_DRIVER, CodexProviderCapabilitiesV2], + [CLAUDE_MODEL_SELECTION, CLAUDE_DRIVER, ClaudeProviderCapabilitiesV2], + ] as const + ).map(([modelSelection, driver, capabilities]) => + makeTestAdapter({ + instanceId: modelSelection.instanceId, + driver, + capabilities, + modelSelection, + responseByRunOrdinal: { [sourceOrdinal]: partialResponse }, + capturedTurns, + ...(native && modelSelection.instanceId === targetSelection.instanceId + ? { injectedHistory } + : {}), + ...(modelSelection.instanceId === sourceSelection.instanceId + ? { + failedRunOrdinals: new Set( + status === "failed" ? [sourceOrdinal] : [], + ), + interruptedRunOrdinals: new Set( + status === "interrupted" ? [sourceOrdinal] : [], + ), + holdRunOrdinal: sourceOrdinal, + holdFirstTurn: started, + releaseFirstTurn: release, + } + : {}), + }), + ), + ); + yield* Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const worker = yield* OrchestrationEffectWorkerV2; + const waitForRun = ( + ordinal: number, + expectedStatus: "completed" | "failed" | "interrupted", + ) => + orchestrator.streamStoredEvents.pipe( + Stream.filter( + ({ event }) => + event.type === "run.updated" && + event.threadId === threadId && + event.payload.ordinal === ordinal && + event.payload.status === expectedStatus, + ), + Stream.runHead, + Effect.andThen(worker.drain()), + ); + const dispatch = ( + key: string, + text: string, + modelSelection: ModelSelection, + queue = false, + ) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`command:handoff:${key}`), + threadId, + messageId: MessageId.make(`message:handoff:${key}`), + createdBy: "user", + creationSource: "web", + text, + attachments: [], + modelSelection, + dispatchMode: { type: queue ? "queue_after_active" : "start_immediately" }, + }); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("command:handoff:create"), threadId, - messageId: MessageId.make(`message:handoff:${key}`), + projectId, createdBy: "user", creationSource: "web", - text, - attachments: [], - modelSelection, - dispatchMode: { type: queue ? "queue_after_active" : "start_immediately" }, + title: "Interrupted provider handoff", + modelSelection: CODEX_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, }); - yield* orchestrator.dispatch({ - type: "thread.create", - commandId: CommandId.make("command:handoff:create"), - threadId, - projectId, - createdBy: "user", - creationSource: "web", - title: "Interrupted provider handoff", - modelSelection: CODEX_MODEL_SELECTION, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - }); - if (returning) { - yield* dispatch( - "initial", - "Earlier successful request.", - CODEX_MODEL_SELECTION, + if (returning) { + yield* dispatch( + "initial", + "Earlier successful request.", + CODEX_MODEL_SELECTION, + ); + yield* waitForRun(1, "completed"); + } + yield* dispatch("source", originalPrompt, sourceSelection); + yield* Deferred.await(started); + if (queued) { + yield* dispatch("target", "Continue", targetSelection, true); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status, + "queued", + ); + } + yield* Deferred.succeed(release, undefined); + yield* waitForRun(sourceOrdinal, status); + if (!queued) { + yield* dispatch("target", "Continue", targetSelection); + } + yield* waitForRun(sourceOrdinal + 1, "completed"); + const projection = yield* orchestrator.getThreadProjection(threadId); + const targetRun = projection.runs.at(-1)!; + const handoff = projection.contextHandoffs.find( + (candidate) => candidate.id === targetRun.contextHandoffId, ); - yield* waitForRun(1, "completed"); - } - yield* dispatch("source", originalPrompt, sourceSelection); - yield* Deferred.await(started); - if (queued) { - yield* dispatch("target", "Continue", targetSelection, true); + assert.isDefined(handoff); assert.equal( - (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status, - "queued", + handoff?.strategy, + returning ? "delta_since_target_last_seen" : "full_thread_summary", ); - } - yield* Deferred.succeed(release, undefined); - yield* waitForRun(sourceOrdinal, status); - if (!queued) { - yield* dispatch("target", "Continue", targetSelection); - } - yield* waitForRun(sourceOrdinal + 1, "completed"); - const projection = yield* orchestrator.getThreadProjection(threadId); - const targetRun = projection.runs.at(-1)!; - const handoff = projection.contextHandoffs.find( - (candidate) => candidate.id === targetRun.contextHandoffId, - ); - assert.isDefined(handoff); - assert.equal( - handoff?.strategy, - returning ? "delta_since_target_last_seen" : "full_thread_summary", - ); - assert.deepEqual(handoff?.coveredRunOrdinals, { - from: sourceOrdinal, - to: sourceOrdinal, - }); - const delivered = (yield* Ref.get(capturedTurns)).at(-1)!; - assert.include(delivered.text, originalPrompt); - assert.include(delivered.text, partialResponse); - assert.include(delivered.text, "User message:\nContinue"); - assert.notInclude(delivered.text, "Earlier successful request."); - }).pipe( - Effect.provide( - makeOrchestratorV2ReplayLayerWithRegistry( - { - name: `handoff-${status}-${queued}-${returning}`, - runtimePolicyOverride: { - cwd, - approvalPolicy: "never", - sandboxPolicy: { - type: "readOnly", - access: { type: "fullAccess" }, - networkAccess: false, + assert.deepEqual(handoff?.coveredRunOrdinals, { + from: sourceOrdinal, + to: sourceOrdinal, + }); + const delivered = (yield* Ref.get(capturedTurns)).at(-1)!; + const history = yield* Ref.get(injectedHistory); + const deliveredHistory = native ? yield* encodeJson(history) : delivered.text; + assert.include(deliveredHistory, originalPrompt); + assert.include(deliveredHistory, partialResponse); + assert.include(deliveredHistory, `run-status=${status}`); + if (native) { + assert.equal(delivered.text, "Continue"); + assert.notInclude(deliveredHistory, '"text":"Continue"'); + assert.include(deliveredHistory, '"role":"assistant"'); + assert.include(deliveredHistory, '"role":"user"'); + assert.equal(handoff?.delivery?.status, "injected"); + } else { + assert.include(delivered.text, "User message:\nContinue"); + assert.equal(handoff?.delivery?.status, "inline"); + } + assert.notInclude(deliveredHistory, "Earlier successful request."); + // The returning source already has its own failed/interrupted native turn. + yield* dispatch("back", "Finish the remaining work", sourceSelection); + yield* waitForRun(sourceOrdinal + 2, "completed"); + const back = (yield* Ref.get(capturedTurns)).at(-1)!; + assert.notInclude(back.text, originalPrompt); + assert.notInclude(back.text, partialResponse); + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { + name: `handoff-${status}-${queued}-${returning}`, + runtimePolicyOverride: { + cwd, + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, }, }, - }, - registryLayer, + registryLayer, + ), ), - ), - ); - }), - ), - ); + ); + }), + ), + ); + } } } } diff --git a/apps/server/src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts index 9acc0baf831d..d656f7b4b930 100644 --- a/apps/server/src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts @@ -14,6 +14,8 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import { ProviderAdapterRegistryV2 } from "../ProviderAdapterRegistry.ts"; import { ClaudeOrchestratorReplayHarness } from "../Adapters/ClaudeAdapterV2.testkit.ts"; import { CodexOrchestratorReplayHarness } from "../Adapters/CodexAdapterV2.testkit.ts"; @@ -40,6 +42,61 @@ import { materializeReplayTranscriptWorkspace, } from "./ReplayTranscriptNdjson.ts"; +// These recorded 0.137 rollouts predate injection. Preserve their native fork +// and turn exchanges, and assert the new history delivery at the adapter boundary. +// Exact thread/inject_items frames are covered by CodexAdapterV2.test.ts. +const CodexHistoryReplayHarness: typeof CodexOrchestratorReplayHarness = { + ...CodexOrchestratorReplayHarness, + makeProviderAdapterRegistryLayer: (transcript, options) => + Layer.effect( + ProviderAdapterRegistryV2, + Effect.gen(function* () { + const registry = yield* ProviderAdapterRegistryV2; + return { + ...registry, + get: (id) => + registry.get(id).pipe( + Effect.map((adapter) => ({ + ...adapter, + openSession: (input) => + adapter.openSession(input).pipe( + Effect.map((session) => ({ + ...session, + injectHistory: (history) => + Effect.sync(() => { + const userTexts = history.messages + .filter((message) => message.role === "user") + .map((message) => message.text); + assert.lengthOf(userTexts, 1); + assert.include( + [ + THREAD_MERGE_BACK_FORK_PROMPT, + THREAD_MERGE_BACK_SIBLINGS_FIRST_FORK_PROMPT, + THREAD_MERGE_BACK_SIBLINGS_SECOND_FORK_PROMPT, + ], + userTexts[0], + ); + assert.isTrue( + history.messages.some( + (message) => + message.role === "assistant" && message.text.includes("stored"), + ), + ); + return true; + }), + })), + ), + })), + ), + }; + }), + ).pipe( + Layer.provide( + CodexOrchestratorReplayHarness.makeProviderAdapterRegistryLayer(transcript, options), + ), + ), +}; + const CODEX_MODEL_SELECTION = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", @@ -317,7 +374,10 @@ describe("orchestration V2 merge-back provider replay", () => { }); const generatedProviderMessage = providerMessage(summary, MERGE_BACK_USER_TEXT); const parameterizedTranscript = parameterizeHandoffs(rawTranscript, [ - [THREAD_MERGE_BACK_HANDOFF_PROMPT, generatedProviderMessage], + [ + THREAD_MERGE_BACK_HANDOFF_PROMPT, + variant.driver === "codex" ? MERGE_BACK_USER_TEXT : generatedProviderMessage, + ], ]); const cwd = yield* Effect.acquireRelease( Effect.promise(() => makeCheckpointWorkspace(`merge-back-${variant.driver}`)), @@ -356,7 +416,7 @@ describe("orchestration V2 merge-back provider replay", () => { materializeReplayTranscriptWorkspace(parameterizedTranscript, cwd), ), }, - CodexOrchestratorReplayHarness, + CodexHistoryReplayHarness, ).pipe(provideDeterministicTestRuntime) : yield* runOrchestratorV2ProviderReplayScenario( { @@ -555,11 +615,15 @@ describe("orchestration V2 merge-back provider replay", () => { const transcript = parameterizeHandoffs(rawTranscript, [ [ THREAD_MERGE_BACK_SIBLINGS_FIRST_HANDOFF_PROMPT, - providerMessage(firstSummary, FIRST_SIBLING_MERGE_USER_TEXT), + variant.driver === "codex" + ? FIRST_SIBLING_MERGE_USER_TEXT + : providerMessage(firstSummary, FIRST_SIBLING_MERGE_USER_TEXT), ], [ THREAD_MERGE_BACK_SIBLINGS_SECOND_HANDOFF_PROMPT, - providerMessage(secondSummary, SECOND_SIBLING_MERGE_USER_TEXT), + variant.driver === "codex" + ? SECOND_SIBLING_MERGE_USER_TEXT + : providerMessage(secondSummary, SECOND_SIBLING_MERGE_USER_TEXT), ], ]); const cwd = yield* Effect.acquireRelease( @@ -610,7 +674,7 @@ describe("orchestration V2 merge-back provider replay", () => { materializeReplayTranscriptWorkspace(transcript, cwd), ), }, - CodexOrchestratorReplayHarness, + CodexHistoryReplayHarness, ).pipe(provideDeterministicTestRuntime) : yield* runOrchestratorV2ProviderReplayScenario( { diff --git a/docs/user/portable-handoffs.md b/docs/user/portable-handoffs.md index 9555b2f426c0..3e711db3edf5 100644 --- a/docs/user/portable-handoffs.md +++ b/docs/user/portable-handoffs.md @@ -1,19 +1,35 @@ # Context in portable handoffs -T3 Code sometimes starts a fresh provider session instead of resuming a provider's native session. -This happens when you switch providers, continue through portable restart steering, or use a fork -that the provider cannot resume itself. - -The new provider receives a text handoff. This handoff is useful context, but it is not a copy of -the original provider session. For each included user message, assistant message, command, or prior -handoff, T3 Code collapses whitespace and keeps up to 240 characters from the beginning. File -changes appear by filename, and checkpoints appear with a file count. Other timeline items may not -appear in the handoff. - -Text near the end of a long message can be omitted. Before switching providers or continuing a -portable fork, put the current goal, hard constraints, and any unresolved failure in a short message. -You can also paste the exact detail again after the new provider starts. - -This 240-character rule is different from migration of older threads. A migrated thread gives its -first fresh provider session the newest transcript suffix within a separate 32,000-character budget. -See [Threads from older T3 Code versions](./thread-migration.md) for that migration path. +T3 Code transfers conversation context when you switch providers, continue through a portable +restart, or use a fork that the provider cannot resume itself. + +Short conversations transfer intact. For longer conversations, T3 Code favors recent requests and +answers, the original request, and relevant activity such as command outcomes. Selected messages +keep their full text, order, and user or assistant role, including partial work from failed or +interrupted turns. Your new request stays separate and is never shortened to make history fit. + +Codex receives historical messages directly when its installed version supports that operation. +Other providers receive the same selection as attributed conversation context. A handoff does not +copy the outgoing provider's reasoning, tool-call state, or attachments. + +The handoff includes references to omitted history. The agent can use T3 Code's thread-reading tool +to retrieve saved messages and activity, including the remainder of a long item. For an important +constraint, you can still repeat it in your next message. A handoff is a budgeted selection, not an +agent-written summary. + +## Context limits + +A handoff must leave room for existing provider context, your request and attachments, instructions, +tools, and subsequent work. If even its retrieval references cannot fit, T3 Code reports an error +instead of shortening your request. Compact the target conversation or select a larger-context +model before trying again. + +Server operators can set `T3CODE_CONTEXT_HANDOFF_TOKEN_CAP` to change the initial history allowance +(default 16,000; clamped to 1,024โ€“64,000). This is an upper bound, not a provider context-window +guarantee. Accounting conservatively charges one token per UTF-8 byte, including attribution and +JSON escaping, and caps handoff plus current-input accounting at 64,000 bytes. Attachments reserve +at least 4,096 units each, or their byte size when larger. + +When provider context telemetry is unavailable, T3 Code assumes a 32,000-token window, reserves at +least 16,000 for instructions, tools, and work, and estimates existing context from saved activity. +Custom models and hidden native context can differ, so the provider may still reject an input. diff --git a/docs/user/thread-migration.md b/docs/user/thread-migration.md index bbff8ad22610..d03f1528a8dc 100644 --- a/docs/user/thread-migration.md +++ b/docs/user/thread-migration.md @@ -20,9 +20,11 @@ present. ## Continuing a migrated thread -The first new message starts a fresh provider session. T3 Code gives that session the newest part of -the old user and assistant transcript, up to 32,000 characters. Earlier text remains visible in the -thread, but the provider does not receive it automatically. +The first new message starts a fresh provider session. T3 Code selects intact user and assistant +messages using the same [handoff budget](./portable-handoffs.md) as a provider switch. Omitted text +remains in the thread and can be retrieved by the agent. The migration retains its separate +32,000-character recovery excerpt; neither that excerpt nor the handoff replaces the full imported +transcript. Before continuing a long or important thread, read the recent transcript and include any older requirements the agent still needs in your next message. Starting a new thread and pasting a short diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 880cae7b2904..cd0b7ce11d68 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -670,6 +670,19 @@ export const OrchestrationV2ProviderThread = Schema.Struct({ }); export type OrchestrationV2ProviderThread = typeof OrchestrationV2ProviderThread.Type; +export const OrchestrationV2HistoricalMessage = Schema.Struct({ + role: Schema.Literals(["user", "assistant"]), + text: Schema.String, + runStatus: Schema.optional(Schema.String), + threadId: ThreadId, + runId: Schema.NullOr(RunId), + itemId: TurnItemId, + providerThreadId: Schema.NullOr(ProviderThreadId), + status: Schema.String, + kind: Schema.String, +}); +export type OrchestrationV2HistoricalMessage = typeof OrchestrationV2HistoricalMessage.Type; + export const OrchestrationV2ContextHandoff = Schema.Struct({ id: ContextHandoffId, transferId: Schema.optional(Schema.NullOr(ContextTransferId)), @@ -691,6 +704,21 @@ export const OrchestrationV2ContextHandoff = Schema.Struct({ status: Schema.Literals(["pending", "ready", "failed", "superseded"]), summaryMessageId: Schema.NullOr(MessageId), summaryText: Schema.String, + // Optional fields keep existing preview events and projections readable without a migration. + history: Schema.optional( + Schema.Struct({ + messages: Schema.Array(OrchestrationV2HistoricalMessage), + coverage: Schema.String, + omittedItems: NonNegativeInt, + }), + ), + delivery: Schema.optional( + Schema.Struct({ + nativeThreadId: Schema.String, + status: Schema.Literals(["pending", "injected", "inline"]), + itemIds: Schema.Array(TurnItemId), + }), + ), detailInTurnItem: Schema.optional(Schema.Literal(true)), createdByProviderInstanceId: Schema.NullOr(ProviderInstanceId), createdAt: Schema.DateTimeUtc, diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 147948739a8c..19480711491a 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -321,6 +321,8 @@ export type OrchestratorMcpThreadListResult = typeof OrchestratorMcpThreadListRe export const OrchestratorMcpThreadReadInput = Schema.Struct({ threadId: ThreadId, + itemId: Schema.optional(TurnItemId), + textOffset: Schema.optional(NonNegativeInt), view: Schema.optional(Schema.Literals(["messages", "activity"])), afterPosition: Schema.optional(NonNegativeInt), limit: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(100))), @@ -383,6 +385,7 @@ export const OrchestratorMcpThreadTimelineItem = Schema.Struct({ title: Schema.NullOr(Schema.String), text: Schema.NullOr(Schema.String), textTruncated: Schema.Boolean, + nextTextOffset: Schema.optional(Schema.NullOr(NonNegativeInt)), updatedAt: IsoDateTime, }); export type OrchestratorMcpThreadTimelineItem = typeof OrchestratorMcpThreadTimelineItem.Type; From d144a2ba16556acf32deafba6013b22fb81602f4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 17 Sep 2026 19:33:42 -0700 Subject: [PATCH 02/13] fix(server): retain handoffs across compaction and delivery failures --- .../ContextHandoffBudget.test.ts | 36 +++ .../orchestration-v2/ContextHandoffBudget.ts | 4 +- .../ContextHandoffDelivery.ts | 11 +- .../orchestration-v2/ContextHandoffService.ts | 2 +- .../ProviderTurnStartService.ts | 218 ++++++++++-------- .../src/orchestration-v2/WireProjection.ts | 2 +- .../ProviderSwitch.integration.test.ts | 169 +++++++++++++- 7 files changed, 340 insertions(+), 102 deletions(-) diff --git a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts index e45f14bc08e0..151eac1921ee 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts @@ -94,12 +94,14 @@ describe("handoff budget", () => { it("keeps old preview handoffs readable and delivery history off the wire", () => { const projected = projectContextHandoffForWire({ ...handoff, + summaryText: "private transcript and command output", delivery: { nativeThreadId: "native:target", status: "injected", itemIds: [messages[0]!.itemId], }, }); + assert.equal(projected.summaryText, ""); assert.isUndefined(projected.history); assert.isUndefined(projected.delivery); assert.equal(decodeHandoff(projected).id, handoff.id); @@ -285,6 +287,40 @@ describe("handoff delivery", () => { }), ); + it.effect( + "defers unsupported compaction history until a normal turn without marking delivery uncertain", + () => + Effect.gen(function* () { + let durable = handoff; + const persist = (value: OrchestrationV2ContextHandoff) => + Effect.sync(() => { + durable = value; + }); + const compact = yield* deliverContextHandoffs({ + handoffs: [durable], + providerThread, + budget: 16_000, + alreadyDeliveredItemIds: new Set(), + deferInline: true, + inject: () => Effect.succeed(false), + persist, + }); + yield* compact.delivered; + assert.equal(compact.context, ""); + assert.isUndefined(durable.delivery); + const next = yield* deliverContextHandoffs({ + handoffs: [durable], + providerThread, + budget: 16_000, + alreadyDeliveredItemIds: new Set(), + persist, + }); + assert.include(next.context, messages[0]!.text); + yield* next.delivered; + assert.equal(durable.delivery?.status, "inline"); + }), + ); + it.effect("does not redeliver after ambiguous injection failure", () => Effect.gen(function* () { let durable = handoff; diff --git a/apps/server/src/orchestration-v2/ContextHandoffBudget.ts b/apps/server/src/orchestration-v2/ContextHandoffBudget.ts index cf12ab45c207..8c4ab9e80dba 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffBudget.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffBudget.ts @@ -9,7 +9,7 @@ import type { import * as Config from "effect/Config"; export const DEFAULT_HANDOFF_TOKEN_CAP = 16_000; -export const HANDOFF_BYTE_CAP = 64_000; +const HANDOFF_BYTE_CAP = 64_000; export const handoffTokenCapConfig = Config.int("T3CODE_CONTEXT_HANDOFF_TOKEN_CAP").pipe( Config.withDefault(DEFAULT_HANDOFF_TOKEN_CAP), Config.map((value) => Math.max(1_024, Math.min(HANDOFF_BYTE_CAP, value))), @@ -87,7 +87,7 @@ export function historicalMessage( }; } -export function renderHistoricalMessage(message: OrchestrationV2HistoricalMessage): string { +function renderHistoricalMessage(message: OrchestrationV2HistoricalMessage): string { return `[Historical ${message.role}; ${message.kind}; thread=${message.threadId}; run=${message.runId ?? "imported"}; item=${message.itemId}; provider-thread=${message.providerThreadId ?? "none"}; status=${message.status}${message.runStatus === undefined ? "" : `; run-status=${message.runStatus}`}]\n${message.text}`; } diff --git a/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts b/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts index c33bb1b093b2..28f8ddaedec4 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts @@ -13,6 +13,7 @@ export const deliverContextHandoffs = Effect.fn("orchestrationV2.deliverContextH readonly handoffs: ReadonlyArray; readonly providerThread: OrchestrationV2ProviderThread; readonly budget: number; + readonly deferInline?: boolean; readonly alreadyDeliveredItemIds: ReadonlySet; readonly inject?: ( history: ProviderAdapterV2HistoricalContext, @@ -26,7 +27,8 @@ export const deliverContextHandoffs = Effect.fn("orchestrationV2.deliverContextH handoff.delivery?.nativeThreadId !== nativeThreadId || handoff.delivery.status === "pending", ); - if (pending.length === 0) return { context: "", delivered: Effect.void }; + if (pending.length === 0 || (input.deferInline && input.inject === undefined)) + return { context: "", delivered: Effect.void }; const coverage = pending .map( (handoff) => @@ -61,6 +63,7 @@ export const deliverContextHandoffs = Effect.fn("orchestrationV2.deliverContextH budget: input.budget, }); if (historyCost(selected.messages, selected.context) > input.budget) { + if (input.deferInline) return { context: "", delivered: Effect.void }; return yield* new ContextHandoffBudgetError(); } const persist = (status: "pending" | "injected" | "inline") => @@ -110,6 +113,12 @@ export const deliverContextHandoffs = Effect.fn("orchestrationV2.deliverContextH // Text-only delivery can also be accepted before a connection drops. yield* persist("pending"); } + if (input.deferInline) { + // Compaction APIs cannot accept an inline transcript. Keep it available + // for the next ordinary turn when native injection is unsupported. + yield* Effect.forEach(pending, input.persist, { discard: true }); + return { context: "", delivered: Effect.void }; + } return { context: renderHistory(selected.messages, selected.context), delivered: persist("inline"), diff --git a/apps/server/src/orchestration-v2/ContextHandoffService.ts b/apps/server/src/orchestration-v2/ContextHandoffService.ts index d566cdcdce59..2f5e05e20826 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffService.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffService.ts @@ -212,7 +212,7 @@ export function providerMessageWithContextHandoff(input: { }); } -export function providerMessageWithContextHandoffs(input: { +function providerMessageWithContextHandoffs(input: { readonly handoffs: ReadonlyArray; readonly userText: string; }): string { diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index b4b5f1527f08..81131c7379ac 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -128,7 +128,11 @@ export const layer: Layer.Layer< projection.runs.some( (source) => source.id === handoff.targetRunId && - (source.status === "failed" || source.status === "interrupted"), + (source.status === "failed" || + source.status === "interrupted" || + (source.status === "completed" && + handoff.history !== undefined && + handoff.delivery === undefined)), ))), ); const nativeForkTransfer = projection.contextTransfers.find( @@ -690,7 +694,10 @@ export const layer: Layer.Layer< const nativeContextBytes = () => sameNativeThread ? projection.turnItems.reduce((sum, item) => { - if (item.providerThreadId !== providerThread.id && !deliveredItemIds.has(item.id)) + if ( + item.runId === run.id || + (item.providerThreadId !== providerThread.id && !deliveredItemIds.has(item.id)) + ) return sum; const historical = historicalMessage(item); return sum + (historical === null ? 0 : Buffer.byteLength(historical.text)); @@ -729,104 +736,125 @@ export const layer: Layer.Layer< !deliveredItemIds.has(item.id) && historicalMessage(item) !== null, ); + const startWithHandoffs = ( + turnInput: Parameters[0], + compact = false, + ) => + Effect.gen(function* () { + // A failed turn/start can leave the requested turn absent from + // native history even when its preceding handoff was injected. + const retryHandoff = + missedItems.length === 0 + ? [] + : [ + yield* contextHandoffService.prepareProviderHandoff({ + threadId: projection.thread.id, + targetRunId: run.id, + transferId: null, + fromProviderThreadIds: [providerThread.id], + toProviderThreadId: providerThread.id, + fromProviderInstanceId: run.providerInstanceId, + toProviderInstanceId: run.providerInstanceId, + coveredRunOrdinals: { + from: missedRuns[0]!.ordinal, + to: missedRuns.at(-1)!.ordinal, + }, + strategy: "delta_since_target_last_seen", + items: missedItems, + runs: projection.runs, + createdAt: yield* DateTime.now, + }), + ]; + const delivery = yield* deliverContextHandoffs({ + handoffs: [...effectiveHandoffs, ...retryHandoff], + deferInline: compact, + providerThread: runningProviderThread, + budget: handoffBudget({ + tokenCap, + userText, + attachments: message.attachments, + providerThread: budgetProviderThread, + nativeContextBytes: + budgetProviderThread.contextUsage?.usedTokens === undefined + ? nativeContextBytes() + : 0, + }), + alreadyDeliveredItemIds: deliveredItemIds, + ...(session.injectHistory === undefined + ? {} + : { + inject: (history: ProviderAdapterV2HistoricalContext) => + session.injectHistory!({ + providerThread: runningProviderThread, + ...history, + }), + }), + persist: (handoff) => + Effect.gen(function* () { + const updatedAt = yield* DateTime.now; + yield* eventSink.write({ + events: [ + { + id: yield* idAllocator.allocate.event({ + threadId: projection.thread.id, + }), + type: "context-handoff.updated", + threadId: projection.thread.id, + runId: run.id, + providerInstanceId: run.providerInstanceId, + occurredAt: updatedAt, + payload: { ...handoff, updatedAt }, + }, + ], + }); + }), + }); + if (!(yield* isCurrentAttemptInStatus("running"))) return; + const start = compact ? session.compactThread! : session.startTurn; + yield* start({ + ...turnInput, + message: { + ...turnInput.message, + text: + delivery.context === "" + ? userText + : `${delivery.context}\n\nUser message:\n${userText}`, + }, + }); + // The provider already accepted the turn. A stale pending marker + // can force a fresh thread later, but must not stop live ingestion. + yield* delivery.delivered.pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to record accepted context handoff delivery", { + runId: run.id, + cause: Cause.pretty(cause), + }), + ), + ); + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterTurnStartError({ + driver: session.driver, + threadId: projection.thread.id, + providerThreadId: providerThread.id, + runId: run.id, + cause, + }), + ), + ); const deliverySession = effectiveHandoffs.length === 0 && missedItems.length === 0 ? session : { ...session, - startTurn: (turnInput: Parameters[0]) => - Effect.gen(function* () { - // A failed turn/start can leave the requested turn absent from - // native history even when its preceding handoff was injected. - const retryHandoff = - missedItems.length === 0 - ? [] - : [ - yield* contextHandoffService.prepareProviderHandoff({ - threadId: projection.thread.id, - targetRunId: run.id, - transferId: null, - fromProviderThreadIds: [providerThread.id], - toProviderThreadId: providerThread.id, - fromProviderInstanceId: run.providerInstanceId, - toProviderInstanceId: run.providerInstanceId, - coveredRunOrdinals: { - from: missedRuns[0]!.ordinal, - to: missedRuns.at(-1)!.ordinal, - }, - strategy: "delta_since_target_last_seen", - items: missedItems, - runs: projection.runs, - createdAt: yield* DateTime.now, - }), - ]; - const delivery = yield* deliverContextHandoffs({ - handoffs: [...effectiveHandoffs, ...retryHandoff], - providerThread: runningProviderThread, - budget: handoffBudget({ - tokenCap, - userText, - attachments: message.attachments, - providerThread: budgetProviderThread, - nativeContextBytes: - budgetProviderThread.contextUsage?.usedTokens === undefined - ? nativeContextBytes() - : 0, - }), - alreadyDeliveredItemIds: deliveredItemIds, - ...(session.injectHistory === undefined - ? {} - : { - inject: (history: ProviderAdapterV2HistoricalContext) => - session.injectHistory!({ - providerThread: runningProviderThread, - ...history, - }), - }), - persist: (handoff) => - Effect.gen(function* () { - const updatedAt = yield* DateTime.now; - yield* eventSink.write({ - events: [ - { - id: yield* idAllocator.allocate.event({ - threadId: projection.thread.id, - }), - type: "context-handoff.updated", - threadId: projection.thread.id, - runId: run.id, - providerInstanceId: run.providerInstanceId, - occurredAt: updatedAt, - payload: { ...handoff, updatedAt }, - }, - ], - }); - }), - }); - if (!(yield* isCurrentAttemptInStatus("running"))) return; - yield* session.startTurn({ - ...turnInput, - message: { - ...turnInput.message, - text: - delivery.context === "" - ? userText - : `${delivery.context}\n\nUser message:\n${userText}`, - }, - }); - yield* delivery.delivered; - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapterTurnStartError({ - driver: session.driver, - threadId: projection.thread.id, - providerThreadId: providerThread.id, - runId: run.id, - cause, - }), - ), - ), + startTurn: startWithHandoffs, + ...(session.compactThread === undefined + ? {} + : { + compactThread: (turnInput: Parameters[0]) => + startWithHandoffs(turnInput, true), + }), }; yield* runExecution.startRootRun({ commandId: CommandId.make(`command:effect:provider-turn.start:${run.id}`), diff --git a/apps/server/src/orchestration-v2/WireProjection.ts b/apps/server/src/orchestration-v2/WireProjection.ts index 8ceace137f11..8fe76bd05a7b 100644 --- a/apps/server/src/orchestration-v2/WireProjection.ts +++ b/apps/server/src/orchestration-v2/WireProjection.ts @@ -109,7 +109,7 @@ export function projectContextHandoffForWire( handoff: OrchestrationV2ContextHandoff, ): OrchestrationV2ContextHandoff { const { history: _history, delivery: _delivery, ...projected } = handoff; - return projected; + return { ...projected, summaryText: "" }; } export function projectThreadProjectionForWire( diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index be4699ede1de..336ad47592e9 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -1,3 +1,4 @@ +import { vi } from "vite-plus/test"; import { historyResponseItems } from "../ContextHandoffBudget.ts"; import { assert, describe, it } from "@effect/vitest"; import { @@ -36,7 +37,7 @@ import { CodexProviderCapabilitiesV2 } from "../Adapters/CodexAdapterV2.ts"; import { AcpProviderCapabilitiesV2 } from "../Adapters/AcpAdapterV2.ts"; import { CursorProviderCapabilitiesV2 } from "../Adapters/CursorAdapterV2.ts"; import { layer as eventSinkLayer } from "../EventSink.ts"; -import { EventSinkV2 } from "../EventSink.ts"; +import { EventSinkV2, EventSinkWriteError } from "../EventSink.ts"; import { layer as eventStoreLayer } from "../EventStore.ts"; import { LegacyV1ThreadImporter, @@ -55,6 +56,7 @@ import { type ProviderAdapterV2HistoricalContext, ProviderAdapterProtocolError, type ProviderAdapterV2Shape, + type ProviderAdapterV2SessionRuntime, } from "../ProviderAdapter.ts"; import { makeLayer as makeProviderAdapterRegistryLayer } from "../ProviderAdapterRegistry.ts"; import { @@ -135,7 +137,7 @@ function makeTestAdapter(input: { lastError: null, }; - return { + const runtime: ProviderAdapterV2SessionRuntime = { instanceId: input.instanceId, driver: input.driver, providerSessionId: sessionInput.providerSessionId, @@ -198,6 +200,7 @@ function makeTestAdapter(input: { ), ), }), + compactThread: (turnInput) => runtime.startTurn(turnInput), startTurn: (turnInput) => Effect.gen(function* () { if ( @@ -318,6 +321,7 @@ function makeTestAdapter(input: { forkThread: () => unimplemented(input.driver, "forkThread unused in provider switch test"), }; + return runtime; }), }; } @@ -341,6 +345,167 @@ const waitForIdle = Effect.fn("ProviderSwitchTest.waitForIdle")(function* ( }); describe("orchestration v2 provider switching", () => { + for (const scenario of [ + "compact-native", + "compact-fallback", + "large-current-input", + "delivery-write-failure", + ] as const) { + it.live(`preserves handoffs through ${scenario}`, () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace(`handoff-${scenario}`); + const capturedTurns = yield* Ref.make>([]); + const injectedHistory = yield* Ref.make>([]); + const registry = makeProviderAdapterRegistryLayer([ + makeTestAdapter({ + instanceId: CODEX_MODEL_SELECTION.instanceId, + driver: CODEX_DRIVER, + capabilities: CodexProviderCapabilitiesV2, + modelSelection: CODEX_MODEL_SELECTION, + responseByRunOrdinal: { 1: "Original partial work" }, + capturedTurns, + }), + makeTestAdapter({ + instanceId: CLAUDE_MODEL_SELECTION.instanceId, + driver: CLAUDE_DRIVER, + capabilities: ClaudeProviderCapabilitiesV2, + modelSelection: CLAUDE_MODEL_SELECTION, + responseByRunOrdinal: {}, + capturedTurns, + ...(scenario === "compact-native" || scenario === "large-current-input" + ? { injectedHistory } + : {}), + }), + ]); + yield* Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const worker = yield* OrchestrationEffectWorkerV2; + const eventSink = yield* EventSinkV2; + const dispatch = (ordinal: number, text: string, selection: ModelSelection) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`regression:${ordinal}`), + threadId, + messageId: MessageId.make(`regression:${ordinal}`), + createdBy: "user", + creationSource: "web", + text, + attachments: [], + modelSelection: selection, + dispatchMode: { type: "start_immediately" }, + }); + const wait = (ordinal: number) => + orchestrator.streamStoredEvents.pipe( + Stream.filter( + ({ event }) => + event.type === "run.updated" && + event.payload.ordinal === ordinal && + (event.payload.status === "completed" || event.payload.status === "failed"), + ), + Stream.runHead, + Effect.andThen(worker.drain()), + ); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("regression:create"), + threadId, + projectId, + createdBy: "user", + creationSource: "web", + title: "Handoff regression", + modelSelection: CODEX_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* dispatch(1, "Original request with constraints", CODEX_MODEL_SELECTION); + yield* wait(1); + const write = eventSink.write; + const spy = + scenario === "delivery-write-failure" + ? vi.spyOn(eventSink, "write").mockImplementation((input) => + input.events.some( + (event) => + event.type === "context-handoff.updated" && + event.payload.delivery?.status === "inline", + ) + ? Effect.fail( + new EventSinkWriteError({ + eventCount: input.events.length, + cause: "bookkeeping unavailable", + }), + ) + : write(input), + ) + : undefined; + yield* Effect.addFinalizer(() => Effect.sync(() => spy?.mockRestore())); + const current = scenario.startsWith("compact") + ? "/compact" + : scenario === "large-current-input" + ? "x".repeat(9_000) + : "Continue work"; + // First establish the returning native thread: the current request must + // not be charged as existing context on the subsequent handoff. + if (scenario === "large-current-input") { + yield* dispatch(2, "Establish target", CLAUDE_MODEL_SELECTION); + yield* wait(2); + yield* dispatch(3, "New source constraint", CODEX_MODEL_SELECTION); + yield* wait(3); + } + const targetOrdinal = scenario === "large-current-input" ? 4 : 2; + yield* dispatch(targetOrdinal, current, CLAUDE_MODEL_SELECTION); + yield* wait(targetOrdinal); + const projection = yield* orchestrator.getThreadProjection(threadId); + assert.equal(projection.runs.at(-1)?.status, "completed"); + const handoff = projection.contextHandoffs.at(-1)!; + if (scenario === "compact-fallback") { + assert.isUndefined(handoff.delivery); + yield* dispatch(3, "Continue after compact", CLAUDE_MODEL_SELECTION); + yield* wait(3); + assert.include( + (yield* Ref.get(capturedTurns)).at(-1)!.text, + "Original request with constraints", + ); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).contextHandoffs.at(-1)?.delivery + ?.status, + "inline", + ); + } else if (scenario === "delivery-write-failure") { + assert.equal(handoff.delivery?.status, "pending"); + assert.include( + (yield* Ref.get(capturedTurns)).at(-1)!.text, + "Original request with constraints", + ); + } else { + assert.equal(handoff.delivery?.status, "injected"); + assert.equal((yield* Ref.get(capturedTurns)).at(-1)!.text, current); + assert.include( + yield* encodeJson(yield* Ref.get(injectedHistory)), + "Original request with constraints", + ); + } + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { + name: `handoff-${scenario}`, + runtimePolicyOverride: { + cwd, + approvalPolicy: "never", + sandboxPolicy: { type: "readOnly" }, + }, + }, + registry, + ), + ), + ); + }), + ), + ); + } for (const failure of ["turn-start", "injection"] as const) { it.live( `recovers ${failure} failure without duplicating history in the same native thread`, From 36cc73aa82178ad34047d4b25ab2d0c988c248e4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 17 Sep 2026 19:43:44 -0700 Subject: [PATCH 03/13] fix(server): preserve pending handoffs during native recovery --- .../ContextHandoffBudget.test.ts | 3 +- .../ProviderTurnStartService.ts | 14 +- .../orchestration-v2/WireProjection.test.ts | 28 ++ .../src/orchestration-v2/WireProjection.ts | 4 + .../ProviderSwitch.integration.test.ts | 433 ++++++++++-------- 5 files changed, 295 insertions(+), 187 deletions(-) diff --git a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts index 151eac1921ee..5d3a7528c1a0 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts @@ -307,12 +307,13 @@ describe("handoff delivery", () => { }); yield* compact.delivered; assert.equal(compact.context, ""); - assert.isUndefined(durable.delivery); + assert.isUndefined((() => durable.delivery)()); const next = yield* deliverContextHandoffs({ handoffs: [durable], providerThread, budget: 16_000, alreadyDeliveredItemIds: new Set(), + inject: () => Effect.succeed(false), persist, }); assert.include(next.context, messages[0]!.text); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 81131c7379ac..3a651bcdd540 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -131,8 +131,13 @@ export const layer: Layer.Layer< (source.status === "failed" || source.status === "interrupted" || (source.status === "completed" && - handoff.history !== undefined && - handoff.delivery === undefined)), + handoff.delivery === undefined && + projection.messages.some( + (message) => + message.id === source.userMessageId && + message.attachments.length === 0 && + message.text.trim().toLowerCase() === "/compact", + ))), ))), ); const nativeForkTransfer = projection.contextTransfers.find( @@ -501,7 +506,7 @@ export const layer: Layer.Layer< ), createdAt, }); - effectiveHandoffs = [handoff]; + effectiveHandoffs = [handoff, ...effectiveHandoffs]; yield* eventSink.write({ events: [ { @@ -696,6 +701,9 @@ export const layer: Layer.Layer< ? projection.turnItems.reduce((sum, item) => { if ( item.runId === run.id || + (item.runId !== null && + missedRunIds.has(item.runId) && + !deliveredItemIds.has(item.id)) || (item.providerThreadId !== providerThread.id && !deliveredItemIds.has(item.id)) ) return sum; diff --git a/apps/server/src/orchestration-v2/WireProjection.test.ts b/apps/server/src/orchestration-v2/WireProjection.test.ts index f778f62bb72b..846ed1f1a1f1 100644 --- a/apps/server/src/orchestration-v2/WireProjection.test.ts +++ b/apps/server/src/orchestration-v2/WireProjection.test.ts @@ -1,5 +1,7 @@ import { MessageId, + ContextHandoffId, + ProviderThreadId, NodeId, EventId, ProjectId, @@ -43,6 +45,32 @@ const base = { }; describe("orchestration V2 wire projection", () => { + it("keeps copied handoff transcripts out of activity items and live events", () => { + const item = { + ...base, + type: "handoff" as const, + contextHandoffId: ContextHandoffId.make("handoff:wire"), + fromProviderThreadIds: [], + toProviderThreadId: ProviderThreadId.make("target"), + fromProviderInstanceIds: [], + toProviderInstanceId: ProviderInstanceId.make("codex"), + strategy: "full_thread_summary" as const, + summary: "PRIVATE_HANDOFF_TRANSCRIPT", + }; + const projected = projectTurnItemForWire(item); + expect(projected).not.toHaveProperty("summary"); + expect(decodeTurnItem(projected)).toMatchObject({ contextHandoffId: item.contextHandoffId }); + const event = { + id: EventId.make("handoff:wire"), + type: "turn-item.updated" as const, + threadId: base.threadId, + occurredAt: base.updatedAt, + payload: item, + }; + expect(JSON.stringify(projectDomainEventForWire(event))).not.toContain(item.summary); + expect(item.summary).toBe("PRIVATE_HANDOFF_TRANSCRIPT"); + }); + it("preserves image metadata through wire and JSON contracts while redacting output", () => { const item = { ...base, diff --git a/apps/server/src/orchestration-v2/WireProjection.ts b/apps/server/src/orchestration-v2/WireProjection.ts index 8fe76bd05a7b..b72cd0542457 100644 --- a/apps/server/src/orchestration-v2/WireProjection.ts +++ b/apps/server/src/orchestration-v2/WireProjection.ts @@ -67,6 +67,10 @@ function summarizeDynamicValue(value: unknown): unknown { export function projectTurnItemForWire(item: OrchestrationV2TurnItem): OrchestrationV2TurnItem { switch (item.type) { + case "handoff": { + const { summary: _summary, ...projected } = item; + return projected; + } case "command_execution": { const { output, ...projected } = item; // Clients used this preview to recognize provider-reported failures. Keep diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index 336ad47592e9..91941f35fdf5 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -348,6 +348,7 @@ describe("orchestration v2 provider switching", () => { for (const scenario of [ "compact-native", "compact-fallback", + "compact-legacy", "large-current-input", "delivery-write-failure", ] as const) { @@ -460,7 +461,22 @@ describe("orchestration v2 provider switching", () => { const projection = yield* orchestrator.getThreadProjection(threadId); assert.equal(projection.runs.at(-1)?.status, "completed"); const handoff = projection.contextHandoffs.at(-1)!; - if (scenario === "compact-fallback") { + if (scenario === "compact-fallback" || scenario === "compact-legacy") { + if (scenario === "compact-legacy") { + const { history: _history, ...legacyHandoff } = handoff; + yield* eventSink.write({ + events: [ + { + id: EventId.make("legacy-handoff-shape"), + type: "context-handoff.updated", + threadId, + runId: handoff.targetRunId, + occurredAt: yield* DateTime.now, + payload: legacyHandoff, + }, + ], + }); + } assert.isUndefined(handoff.delivery); yield* dispatch(3, "Continue after compact", CLAUDE_MODEL_SELECTION); yield* wait(3); @@ -506,7 +522,7 @@ describe("orchestration v2 provider switching", () => { ), ); } - for (const failure of ["turn-start", "injection"] as const) { + for (const failure of ["turn-start", "injection", "large-missed-request"] as const) { it.live( `recovers ${failure} failure without duplicating history in the same native thread`, () => @@ -535,7 +551,7 @@ describe("orchestration v2 provider switching", () => { capturedTurns, injectedHistory, nativeThreadGeneration: generation, - ...(failure === "turn-start" + ...(failure !== "injection" ? { failStartOnce: failOnce } : { failInjectionOnce: failOnce }), }), @@ -583,25 +599,32 @@ describe("orchestration v2 provider switching", () => { }); yield* dispatch(1, "Original request with constraints", CODEX_MODEL_SELECTION); yield* wait(1, "completed"); - yield* dispatch(2, "First target request", CLAUDE_MODEL_SELECTION); + yield* dispatch( + 2, + failure === "large-missed-request" ? "x".repeat(7_000) : "First target request", + CLAUDE_MODEL_SELECTION, + ); yield* wait(2, "failed"); const failed = yield* orchestrator.getThreadProjection(threadId); const failedHandoff = failed.contextHandoffs.at(-1)!; assert.equal( failedHandoff.delivery?.status, - failure === "turn-start" ? "injected" : "pending", + failure !== "injection" ? "injected" : "pending", ); const historyBeforeRetry = (yield* Ref.get(injectedHistory)).length; - yield* dispatch(3, "Retry target request", CLAUDE_MODEL_SELECTION); + const retryText = + failure === "large-missed-request" ? "y".repeat(10_000) : "Retry target request"; + yield* dispatch(3, retryText, CLAUDE_MODEL_SELECTION); yield* wait(3, "completed"); const retried = yield* orchestrator.getThreadProjection(threadId); const lastTurn = (yield* Ref.get(capturedTurns)).at(-1)!; - assert.equal(lastTurn.text, "Retry target request"); - if (failure === "turn-start") { + assert.equal(lastTurn.text, retryText); + if (failure !== "injection") { const delta = yield* encodeJson( (yield* Ref.get(injectedHistory)).slice(historyBeforeRetry), ); - assert.include(delta, "First target request"); + if (failure === "large-missed-request") assert.include(delta, "omitted 1 items"); + else assert.include(delta, "First target request"); assert.notInclude(delta, "Original request with constraints"); assert.notInclude(delta, "Original partial work"); assert.equal(yield* Ref.get(generation), 1); @@ -2526,185 +2549,229 @@ describe("orchestration v2 provider switching", () => { ), ); - it.live("switches providers while consuming a pending cross-provider merge-back", () => - Effect.scoped( - Effect.gen(function* () { - const sourceThreadId = ThreadId.make("thread:cross-provider-merge:source"); - const forkThreadId = ThreadId.make("thread:cross-provider-merge:fork"); - const firstSourcePrompt = "Remember that the first source marker is amber."; - const secondSourcePrompt = "Remember that the second source marker is violet."; - const forkPrompt = "Remember that the fork marker is cobalt."; - const mergePrompt = "Report all three remembered markers."; - const cwd = yield* checkpointWorkspace("cross-provider-merge"); - const capturedTurns = yield* Ref.make>([]); - const registryLayer = makeProviderAdapterRegistryLayer([ - makeTestAdapter({ - instanceId: ProviderInstanceId.make("codex"), - driver: CODEX_DRIVER, - capabilities: CodexProviderCapabilitiesV2, - modelSelection: CODEX_MODEL_SELECTION, - responseByRunOrdinal: {}, - responseByThreadId: { - [sourceThreadId]: { - 1: "I will remember amber.", - 3: "The markers are amber, violet, and cobalt.", + for (const failResume of [false, true]) { + it.live( + `switches providers while consuming a pending cross-provider merge-back (resume failure: ${failResume})`, + () => + Effect.scoped( + Effect.gen(function* () { + const sourceThreadId = ThreadId.make("thread:cross-provider-merge:source"); + const forkThreadId = ThreadId.make("thread:cross-provider-merge:fork"); + const firstSourcePrompt = "Remember that the first source marker is amber."; + const secondSourcePrompt = "Remember that the second source marker is violet."; + const forkPrompt = "Remember that the fork marker is cobalt."; + const mergePrompt = "Report all three remembered markers."; + const cwd = yield* checkpointWorkspace("cross-provider-merge"); + const capturedTurns = yield* Ref.make>([]); + const registryLayer = makeProviderAdapterRegistryLayer([ + makeTestAdapter({ + instanceId: ProviderInstanceId.make("codex"), + driver: CODEX_DRIVER, + capabilities: CodexProviderCapabilitiesV2, + modelSelection: CODEX_MODEL_SELECTION, + responseByRunOrdinal: {}, + responseByThreadId: { + [sourceThreadId]: { + 1: "I will remember amber.", + 3: "The markers are amber, violet, and cobalt.", + }, + [forkThreadId]: { + 1: "I will remember cobalt.", + }, + }, + capturedTurns, + failResume, + }), + makeTestAdapter({ + instanceId: ProviderInstanceId.make("claudeAgent"), + driver: CLAUDE_DRIVER, + capabilities: ClaudeProviderCapabilitiesV2, + modelSelection: CLAUDE_MODEL_SELECTION, + responseByRunOrdinal: { 2: "I will remember violet." }, + capturedTurns, + }), + ]); + const commands = [ + { + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:cross-provider-merge:create"), + threadId: sourceThreadId, + projectId, + title: "Cross-provider merge source", + modelSelection: CODEX_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, }, - [forkThreadId]: { - 1: "I will remember cobalt.", + { + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:cross-provider-merge:first-source"), + threadId: sourceThreadId, + messageId: MessageId.make("message:cross-provider-merge:first-source"), + text: firstSourcePrompt, + attachments: [], + modelSelection: CODEX_MODEL_SELECTION, + dispatchMode: { type: "start_immediately" }, }, - }, - capturedTurns, - }), - makeTestAdapter({ - instanceId: ProviderInstanceId.make("claudeAgent"), - driver: CLAUDE_DRIVER, - capabilities: ClaudeProviderCapabilitiesV2, - modelSelection: CLAUDE_MODEL_SELECTION, - responseByRunOrdinal: { 2: "I will remember violet." }, - capturedTurns, - }), - ]); - const commands = [ - { - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cross-provider-merge:create"), - threadId: sourceThreadId, - projectId, - title: "Cross-provider merge source", - modelSelection: CODEX_MODEL_SELECTION, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cross-provider-merge:first-source"), - threadId: sourceThreadId, - messageId: MessageId.make("message:cross-provider-merge:first-source"), - text: firstSourcePrompt, - attachments: [], - modelSelection: CODEX_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cross-provider-merge:second-source"), - threadId: sourceThreadId, - messageId: MessageId.make("message:cross-provider-merge:second-source"), - text: secondSourcePrompt, - attachments: [], - modelSelection: CLAUDE_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }, - { - type: "thread.fork", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cross-provider-merge:fork"), - sourceThreadId, - targetThreadId: forkThreadId, - sourcePoint: { type: "latest_stable" }, - title: "Cross-provider merge fork", - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cross-provider-merge:fork-turn"), - threadId: forkThreadId, - messageId: MessageId.make("message:cross-provider-merge:fork-turn"), - text: forkPrompt, - attachments: [], - modelSelection: CODEX_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }, - { - type: "thread.merge_back", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cross-provider-merge:merge"), - sourceThreadId: forkThreadId, - targetThreadId: sourceThreadId, - sourcePoint: { type: "latest_stable" }, - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cross-provider-merge:consume"), - threadId: sourceThreadId, - messageId: MessageId.make("message:cross-provider-merge:consume"), - text: mergePrompt, - attachments: [], - modelSelection: CODEX_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }, - ] satisfies ReadonlyArray; - - const projection = yield* Effect.gen(function* () { - const orchestrator = yield* OrchestratorV2; - yield* orchestrator.dispatch(commands[0]!); - yield* orchestrator.dispatch(commands[1]!); - yield* waitForIdle(sourceThreadId); - yield* orchestrator.dispatch(commands[2]!); - yield* waitForIdle(sourceThreadId); - yield* orchestrator.dispatch(commands[3]!); - yield* orchestrator.dispatch(commands[4]!); - yield* waitForIdle(forkThreadId); - yield* orchestrator.dispatch(commands[5]!); - yield* orchestrator.dispatch(commands[6]!); - return yield* waitForIdle(sourceThreadId); - }).pipe( - Effect.provide( - makeOrchestratorV2ReplayLayerWithRegistry( { - name: "cross-provider-merge", - runtimePolicyOverride: { - cwd, - approvalPolicy: "never", - sandboxPolicy: { - type: "readOnly", - access: { type: "fullAccess" }, - networkAccess: false, - }, - }, + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:cross-provider-merge:second-source"), + threadId: sourceThreadId, + messageId: MessageId.make("message:cross-provider-merge:second-source"), + text: secondSourcePrompt, + attachments: [], + modelSelection: CLAUDE_MODEL_SELECTION, + dispatchMode: { type: "start_immediately" }, }, - registryLayer, - ), - ), - ); - const turns = yield* Ref.get(capturedTurns); - const mergedTurn = turns.findLast( - (turn) => turn.threadId === sourceThreadId && turn.driver === "codex", - ); - const mergeTransfer = projection.contextTransfers.find( - (transfer) => transfer.type === "merge_back", - ); + { + type: "thread.fork", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:cross-provider-merge:fork"), + sourceThreadId, + targetThreadId: forkThreadId, + sourcePoint: { type: "latest_stable" }, + title: "Cross-provider merge fork", + }, + { + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:cross-provider-merge:fork-turn"), + threadId: forkThreadId, + messageId: MessageId.make("message:cross-provider-merge:fork-turn"), + text: forkPrompt, + attachments: [], + modelSelection: CODEX_MODEL_SELECTION, + dispatchMode: { type: "start_immediately" }, + }, + { + type: "thread.merge_back", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:cross-provider-merge:merge"), + sourceThreadId: forkThreadId, + targetThreadId: sourceThreadId, + sourcePoint: { type: "latest_stable" }, + }, + { + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:cross-provider-merge:consume"), + threadId: sourceThreadId, + messageId: MessageId.make("message:cross-provider-merge:consume"), + text: mergePrompt, + attachments: [], + modelSelection: CODEX_MODEL_SELECTION, + dispatchMode: { type: "start_immediately" }, + }, + ] satisfies ReadonlyArray; - assert.isDefined(mergedTurn); - assert.include(mergedTurn.text, "Context handoff (full_thread_summary):"); - assert.include(mergedTurn.text, firstSourcePrompt); - assert.include(mergedTurn.text, "I will remember amber."); - assert.include(mergedTurn.text, secondSourcePrompt); - assert.include(mergedTurn.text, "I will remember violet."); - assert.include(mergedTurn.text, "Context handoff (merge_back / fork_delta_summary):"); - assert.include(mergedTurn.text, forkPrompt); - assert.include(mergedTurn.text, "I will remember cobalt."); - assert.include(mergedTurn.text, mergePrompt); - assert.isDefined(mergeTransfer); - assert.equal(mergeTransfer.status, "consumed"); - assert.equal(mergeTransfer.targetProviderInstanceId, "codex"); - assert.equal(mergeTransfer.resolution?.strategy, "fork_delta_context"); - }), - ), - ); + const projection = yield* Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + yield* orchestrator.dispatch(commands[0]!); + yield* orchestrator.dispatch(commands[1]!); + yield* waitForIdle(sourceThreadId); + yield* orchestrator.dispatch(commands[2]!); + yield* waitForIdle(sourceThreadId); + yield* orchestrator.dispatch(commands[3]!); + yield* orchestrator.dispatch(commands[4]!); + yield* waitForIdle(forkThreadId); + yield* orchestrator.dispatch(commands[5]!); + if (failResume) { + // A persisted native ref that is not loaded in this process must + // exercise resume rather than the session manager's warm cache. + const beforeResume = yield* orchestrator.getThreadProjection(sourceThreadId); + const codexThread = beforeResume.providerThreads.find( + (thread) => thread.id === beforeResume.runs[0]?.providerThreadId, + )!; + yield* (yield* EventSinkV2).write({ + events: [ + { + id: EventId.make("unloaded-merge-target"), + type: "provider-thread.updated", + threadId: sourceThreadId, + occurredAt: yield* DateTime.now, + payload: { + ...codexThread, + nativeThreadRef: { + ...codexThread.nativeThreadRef!, + nativeId: "unloaded-native-merge-target", + }, + }, + }, + ], + }); + } + yield* orchestrator.dispatch(commands[6]!); + return yield* waitForIdle(sourceThreadId); + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { + name: "cross-provider-merge", + runtimePolicyOverride: { + cwd, + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }, + }, + registryLayer, + ), + ), + ); + const turns = yield* Ref.get(capturedTurns); + const mergedTurn = turns.findLast( + (turn) => turn.threadId === sourceThreadId && turn.driver === "codex", + ); + const mergeTransfer = projection.contextTransfers.find( + (transfer) => transfer.type === "merge_back", + ); + + if (failResume) + assert.isAtLeast( + projection.contextHandoffs.filter( + (handoff) => handoff.targetRunId === projection.runs.at(-1)?.id, + ).length, + 2, + ); + assert.isDefined(mergedTurn); + if (failResume) + assert.notEqual( + projection.providerThreads.find( + (thread) => thread.id === mergedTurn.providerThreadId, + )?.nativeThreadRef?.nativeId, + "unloaded-native-merge-target", + ); + assert.include(mergedTurn.text, "Context handoff (full_thread_summary):"); + assert.include(mergedTurn.text, firstSourcePrompt); + assert.include(mergedTurn.text, "I will remember amber."); + assert.include(mergedTurn.text, secondSourcePrompt); + assert.include(mergedTurn.text, "I will remember violet."); + assert.include(mergedTurn.text, "Context handoff (merge_back / fork_delta_summary):"); + assert.include(mergedTurn.text, forkPrompt); + assert.include(mergedTurn.text, "I will remember cobalt."); + assert.include(mergedTurn.text, mergePrompt); + assert.isDefined(mergeTransfer); + assert.equal(mergeTransfer.status, "consumed"); + assert.equal(mergeTransfer.targetProviderInstanceId, "codex"); + assert.equal(mergeTransfer.resolution?.strategy, "fork_delta_context"); + }), + ), + ); + } it.live("routes two custom instances of the same driver independently", () => Effect.scoped( From 1f720df3b0400039d1f5bcea96fefddbce826a99 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 17 Sep 2026 19:49:14 -0700 Subject: [PATCH 04/13] fix(server): preserve context injection error causes --- .../src/orchestration-v2/Adapters/CodexAdapterV2.test.ts | 5 +++++ .../src/orchestration-v2/Adapters/CodexAdapterV2.ts | 9 +++++---- apps/server/src/orchestration-v2/ProviderAdapter.ts | 1 + .../src/orchestration-v2/ProviderTurnStartService.ts | 6 +++--- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index c6c157e855b8..eb8e633b4f5e 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -1624,6 +1624,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { }).pipe(Effect.result); if (response === "invalid") { assert.equal(injection._tag, "Failure"); + if (injection._tag === "Failure") { + assert.equal(injection.failure._tag, "ProviderAdapterProtocolError"); + assert.propertyVal(injection.failure.cause, "code", -32602); + assert.notProperty(injection.failure, "payload"); + } assert.notInclude(requests, "turn/start"); return; } diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 6d18abfdaa17..04d0d214b841 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -5089,9 +5089,10 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi // Older app servers reject unknown methods before mutating history. // Transport errors and invalid payloads are ambiguous and must not // fall through to a second delivery in the current user message. - Effect.catchTag("CodexAppServerRequestError", (error) => - error.code === -32601 ? Effect.succeed(false) : Effect.fail(error), - ), + Effect.catchTags({ + CodexAppServerRequestError: (error) => + error.code === -32601 ? Effect.succeed(false) : Effect.fail(error), + }), ); }).pipe( Effect.mapError( @@ -5099,7 +5100,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi new ProviderAdapterProtocolError({ driver: CODEX_PROVIDER, detail: "Failed to inject historical context", - payload: cause, + cause, }), ), ), diff --git a/apps/server/src/orchestration-v2/ProviderAdapter.ts b/apps/server/src/orchestration-v2/ProviderAdapter.ts index 5f0826aceb6c..0689a266b556 100644 --- a/apps/server/src/orchestration-v2/ProviderAdapter.ts +++ b/apps/server/src/orchestration-v2/ProviderAdapter.ts @@ -344,6 +344,7 @@ export class ProviderAdapterProtocolError extends Schema.TaggedError Date: Thu, 17 Sep 2026 19:56:40 -0700 Subject: [PATCH 05/13] fix(server): bound handoff recovery markers within the budget --- .../ContextHandoffBudget.test.ts | 44 +++++++++++++++++++ .../orchestration-v2/ContextHandoffBudget.ts | 11 +++-- .../ContextHandoffDelivery.ts | 11 ++++- 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts index 5d3a7528c1a0..acb6fafb3d71 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts @@ -191,6 +191,21 @@ describe("handoff budget", () => { } }); + it("fits the final envelope at intermediate selected/omitted digit boundaries", () => { + const candidates = Array.from({ length: 20 }, (_, index) => + message(`boundary:${index}`, "user", "Short request"), + ); + for (let budget = 4_000; budget <= 9_000; budget++) { + const selected = selectHistory({ + messages: candidates, + coverage: "Recover history", + omittedItems: 90, + budget, + }); + assert.isAtMost(historyCost(selected.messages, selected.context), budget); + } + }); + it("subtracts native usage, input, attachments, instructions and space for work", () => { const base = { tokenCap: 16_000, @@ -322,6 +337,35 @@ describe("handoff delivery", () => { }), ); + it.effect("bounds accumulated recovery markers while keeping omitted history discoverable", () => + Effect.gen(function* () { + const many = Array.from({ length: 100 }, (_, index) => ({ + ...handoff, + id: ContextHandoffId.make(`handoff:retry:${index}`), + })); + let captured: ProviderAdapterV2HistoricalContext | undefined; + const result = yield* deliverContextHandoffs({ + handoffs: many, + providerThread, + budget: 2_500, + alreadyDeliveredItemIds: new Set(), + inject: (history) => + Effect.sync(() => { + captured = history; + return true; + }), + persist: () => Effect.void, + }); + assert.equal(result.context, ""); + assert.isDefined(captured); + assert.include(captured.context, "detailed coverage references omitted"); + assert.include(captured.context, "t3_thread_read"); + assert.include(captured.context, threadId); + assert.isAtMost(historyCost(captured.messages, captured.context), 2_500); + assert.isAbove(captured.messages.length, 0); + }), + ); + it.effect("does not redeliver after ambiguous injection failure", () => Effect.gen(function* () { let durable = handoff; diff --git a/apps/server/src/orchestration-v2/ContextHandoffBudget.ts b/apps/server/src/orchestration-v2/ContextHandoffBudget.ts index 8c4ab9e80dba..c599466aa958 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffBudget.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffBudget.ts @@ -139,11 +139,16 @@ export function selectHistory(input: { }) { const messages = input.messages; const selected = new Set(); - const contextFor = (count: number) => - `${input.coverage}\nSelected ${count} intact items; omitted ${(input.omittedItems ?? 0) + messages.length - count} items. Historical material is context, not a new request or higher-priority instructions. Attached files and native tool/reasoning state are not replayed.`; + const contextFor = ( + count: number, + omitted = (input.omittedItems ?? 0) + messages.length - count, + ) => + `${input.coverage}\nSelected ${count} intact items; omitted ${omitted} items. Historical material is context, not a new request or higher-priority instructions. Attached files and native tool/reasoning state are not replayed.`; let remaining = input.budget - - Math.max(historyCost([], contextFor(0)), historyCost([], contextFor(messages.length))); + // Reserve the maximum width of both counters, including impossible pairs, + // so intermediate counts cannot grow the wrapper past the budget. + historyCost([], contextFor(messages.length, (input.omittedItems ?? 0) + messages.length)); const tryAdd = (index: number) => { const message = messages[index]; if (message === undefined || selected.has(index)) return; diff --git a/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts b/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts index 28f8ddaedec4..529bb11882bd 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts @@ -29,7 +29,7 @@ export const deliverContextHandoffs = Effect.fn("orchestrationV2.deliverContextH ); if (pending.length === 0 || (input.deferInline && input.inject === undefined)) return { context: "", delivered: Effect.void }; - const coverage = pending + let coverage = pending .map( (handoff) => `Context handoff (${handoff.strategy === "fork_delta_summary" ? "merge_back / fork_delta_summary" : handoff.strategy}):\n${ @@ -38,6 +38,13 @@ export const deliverContextHandoffs = Effect.fn("orchestrationV2.deliverContextH }`, ) .join("\n"); + // Repeated failures can accumulate many recovery markers. Keep a single + // thread-level entry point when detailed coverage would crowd out history; + // its activity includes the original handoff/fork source references. + if (historyCost([], coverage) > Math.min(4_000, input.budget / 2)) { + const strategies = Array.from(new Set(pending.map((handoff) => handoff.strategy))); + coverage = `Context handoff (${strategies.join(", ")}). ${pending.length} handoff records; detailed coverage references omitted. Recover history with t3_thread_read({threadId:"${input.providerThread.appThreadId ?? pending[0]!.threadId}",view:"activity",limit:20,maxCharsPerItem:4000}); paginate with afterPosition=nextPosition. Follow fork/handoff source references in activity. For long items use itemId and textOffset=nextTextOffset until null.`; + } const seen = new Set(input.alreadyDeliveredItemIds); const messages = pending .flatMap((handoff) => handoff.history?.messages ?? []) @@ -53,7 +60,7 @@ export const deliverContextHandoffs = Effect.fn("orchestrationV2.deliverContextH .map((handoff) => handoff.summaryText) .join("\n\n"); const fullCoverage = - oldContext && historyCost([], `${coverage}\n${oldContext}`) <= input.budget + oldContext && historyCost([], `${coverage}\n${oldContext}`) + 512 <= input.budget ? `${coverage}\n${oldContext}` : coverage; const selected = selectHistory({ From da19f5150ec7520b14d721ac4c14b1e22a38ea36 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 18 Sep 2026 13:28:37 -0700 Subject: [PATCH 06/13] fix(server): adapt handoff config to Effect rc.115 --- apps/server/src/orchestration-v2/ContextHandoffBudget.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/ContextHandoffBudget.ts b/apps/server/src/orchestration-v2/ContextHandoffBudget.ts index c599466aa958..64e59803dd71 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffBudget.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffBudget.ts @@ -10,7 +10,7 @@ import * as Config from "effect/Config"; export const DEFAULT_HANDOFF_TOKEN_CAP = 16_000; const HANDOFF_BYTE_CAP = 64_000; -export const handoffTokenCapConfig = Config.int("T3CODE_CONTEXT_HANDOFF_TOKEN_CAP").pipe( +export const handoffTokenCapConfig = Config.Int("T3CODE_CONTEXT_HANDOFF_TOKEN_CAP").pipe( Config.withDefault(DEFAULT_HANDOFF_TOKEN_CAP), Config.map((value) => Math.max(1_024, Math.min(HANDOFF_BYTE_CAP, value))), ); From 0118541b56b82e25e5c57833a7071759042dc52f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 18 Sep 2026 14:07:29 -0700 Subject: [PATCH 07/13] fix(server): account for images and omitted handoff coverage --- .../ContextHandoffBudget.test.ts | 91 ++++++++++++++++--- .../orchestration-v2/ContextHandoffBudget.ts | 15 ++- .../ContextHandoffDelivery.ts | 7 ++ .../ContextHandoffService.test.ts | 4 + .../orchestration-v2/ContextHandoffService.ts | 21 ++++- .../ProviderTurnStartService.ts | 18 ++-- .../ProviderSwitch.integration.test.ts | 62 +++++++++++-- packages/contracts/src/orchestrationV2.ts | 4 + 8 files changed, 189 insertions(+), 33 deletions(-) diff --git a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts index acb6fafb3d71..5b21c9650ea1 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts @@ -227,26 +227,89 @@ describe("handoff budget", () => { }), 0, ); - assert.equal( - handoffBudget({ - ...base, - attachments: [ - { - type: "image", - id: "attachment", - name: "image.png", - mimeType: "image/png", - sizeBytes: 40_000, + for (const sizeBytes of [100_000, 10 * 1024 * 1024]) { + const attachments = [ + { + type: "image", + id: "attachment", + name: "image.png", + mimeType: "image/png", + sizeBytes, + }, + ]; + const withImage = handoffBudget({ ...base, attachments }); + assert.isAbove(withImage, 4_000); + assert.isBelow(withImage, handoffBudget(base)); + assert.equal(handoffBudget({ ...base, attachments: [...attachments, ...attachments] }), 0); + assert.equal( + handoffBudget({ + ...base, + tokenCap: 64_000, + // The history byte cap is independent of both current text and image payloads. + userText: "x".repeat(70_000), + attachments, + providerThread: { + ...providerThread, + contextUsage: { usedTokens: 0, maxTokens: 1_000_000 }, }, - ], - }), - 0, - ); + }), + 64_000, + ); + } assert.equal(handoffBudget({ ...base, tokenCap: 2_000 }), 2_000); }); }); describe("handoff delivery", () => { + for (const native of [true, false]) { + it.effect( + `records omitted recovery coverage separately from ${native ? "injected" : "inline"} text`, + () => + Effect.gen(function* () { + const omittedBeforeDelivery = TurnItemId.make("item:omitted-during-preparation"); + const oversized = message("item:oversized", "user", "x".repeat(20_000)); + let durable: OrchestrationV2ContextHandoff = { + ...handoff, + history: { + ...handoff.history!, + messages: [...messages, oversized], + omittedItems: 1, + omittedItemIds: [omittedBeforeDelivery], + }, + }; + const result = yield* deliverContextHandoffs({ + handoffs: [durable], + providerThread, + budget: 16_000, + alreadyDeliveredItemIds: new Set(), + inject: (value) => { + assert.include(value.context, "omitted 2 items"); + assert.notInclude( + value.messages.map((item) => item.itemId), + oversized.itemId, + ); + return Effect.succeed(native); + }, + persist: (value) => + Effect.sync(() => { + durable = value; + }), + }); + assert.equal(durable.delivery?.status, native ? "injected" : "pending"); + yield* result.delivered; + assert.equal(durable.delivery?.status, native ? "injected" : "inline"); + assert.deepEqual( + durable.delivery?.itemIds, + messages.map((item) => item.itemId), + ); + assert.deepEqual(durable.delivery?.omittedItemIds, [ + omittedBeforeDelivery, + oversized.itemId, + ]); + assert.deepEqual(decodeHandoff(durable).delivery, durable.delivery); + }), + ); + } it.effect("persists successful injection before turn start and skips it on retry", () => Effect.gen(function* () { let durable = handoff; diff --git a/apps/server/src/orchestration-v2/ContextHandoffBudget.ts b/apps/server/src/orchestration-v2/ContextHandoffBudget.ts index 64e59803dd71..7c6471009e73 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffBudget.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffBudget.ts @@ -29,8 +29,14 @@ export function handoffBudget(input: { const usage = input.providerThread.contextUsage; const window = Math.min(usage?.maxTokens ?? 32_000, usage?.autoCompactThreshold ?? Infinity); const native = usage?.usedTokens ?? input.nativeContextBytes; + // Encoded image bytes are not model tokens. Without dimensions/detail metadata, + // reserve 8k tokens per image, above typical resized Codex/Claude image costs. + // This is a fallback estimate, not a bound for original-resolution/custom models. + // https://developers.openai.com/api/docs/guides/image-cost-calculator + // https://platform.claude.com/docs/en/build-with-claude/vision + // Other attachments are path references; reserve space for their descriptors. const attachmentAllowance = input.attachments.reduce( - (sum, attachment) => sum + Math.max(4_096, attachment.sizeBytes), + (sum, attachment) => sum + (attachment.type === "image" ? 8_192 : 4_096), 0, ); const current = Buffer.byteLength(JSON.stringify(input.userText)) + attachmentAllowance; @@ -38,7 +44,9 @@ export function handoffBudget(input: { 0, Math.min( input.tokenCap, - HANDOFF_BYTE_CAP - current, + // Cap only imported history. Attachment transport limits belong to adapters; + // they may send binary/base64 data separately from the history request. + HANDOFF_BYTE_CAP, window - native - current - Math.max(16_000, Math.ceil(window / 4)), ), ); @@ -168,6 +176,9 @@ export function selectHistory(input: { for (let index = messages.length - 1; index >= 0; index--) tryAdd(index); return { messages: messages.filter((_, index) => selected.has(index)), + omittedItemIds: messages + .filter((_, index) => !selected.has(index)) + .map((message) => message.itemId), context: contextFor(selected.size), omittedItems: (input.omittedItems ?? 0) + messages.length - selected.size, }; diff --git a/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts b/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts index 529bb11882bd..c4ed32549d92 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts @@ -73,6 +73,7 @@ export const deliverContextHandoffs = Effect.fn("orchestrationV2.deliverContextH if (input.deferInline) return { context: "", delivered: Effect.void }; return yield* new ContextHandoffBudgetError(); } + const omittedItemIds = new Set(selected.omittedItemIds); const persist = (status: "pending" | "injected" | "inline") => Effect.forEach( pending, @@ -85,6 +86,12 @@ export const deliverContextHandoffs = Effect.fn("orchestrationV2.deliverContextH delivery: { nativeThreadId, status, + omittedItemIds: [ + ...(handoff.history?.omittedItemIds ?? []), + ...(handoff.history?.messages ?? []) + .filter((message) => omittedItemIds.has(message.itemId)) + .map((message) => message.itemId), + ], itemIds: selected.messages .filter((message) => handoff.history?.messages.some( diff --git a/apps/server/src/orchestration-v2/ContextHandoffService.test.ts b/apps/server/src/orchestration-v2/ContextHandoffService.test.ts index 7b2311b5f260..872f4eadbd56 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffService.test.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffService.test.ts @@ -155,6 +155,10 @@ it.layer(TestLayer)("ContextHandoffService legacy import", (it) => { createdAt: DateTime.makeUnsafe("2026-01-02T00:00:00.000Z"), }); + assert.include( + handoff.history?.omittedItemIds ?? [], + TurnItemId.make("turn-item:long-single-token"), + ); assert.isAtMost(handoff.summaryText.length, 32_000); assert.include(handoff.summaryText, "User:\n... "); assert.include(handoff.summaryText, "LATEST_SINGLE_TOKEN"); diff --git a/apps/server/src/orchestration-v2/ContextHandoffService.ts b/apps/server/src/orchestration-v2/ContextHandoffService.ts index 2f5e05e20826..be03bf049f4c 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffService.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffService.ts @@ -281,7 +281,12 @@ const makeContextHandoffService = Effect.fn("orchestrationV2.ContextHandoffServi status: "ready", summaryMessageId: null, summaryText: makeLegacyImportSummary(input.items), - history: { messages: selected.messages, coverage, omittedItems: selected.omittedItems }, + history: { + messages: selected.messages, + coverage, + omittedItems: selected.omittedItems, + omittedItemIds: selected.omittedItemIds, + }, createdByProviderInstanceId: null, createdAt: input.createdAt, updatedAt: input.createdAt, @@ -389,7 +394,12 @@ const makeContextHandoffService = Effect.fn("orchestrationV2.ContextHandoffServi status: "ready", summaryMessageId: null, summaryText: makeForkDeltaSummary(input), - history: { messages: selected.messages, coverage, omittedItems: selected.omittedItems }, + history: { + messages: selected.messages, + coverage, + omittedItems: selected.omittedItems, + omittedItemIds: selected.omittedItemIds, + }, createdByProviderInstanceId: null, createdAt: input.createdAt, updatedAt: input.createdAt, @@ -463,7 +473,12 @@ const makeContextHandoffService = Effect.fn("orchestrationV2.ContextHandoffServi status: "ready", summaryMessageId: null, summaryText: renderHistory(selected.messages, selected.context), - history: { messages: selected.messages, coverage, omittedItems: selected.omittedItems }, + history: { + messages: selected.messages, + coverage, + omittedItems: selected.omittedItems, + omittedItemIds: selected.omittedItemIds, + }, createdByProviderInstanceId: null, createdAt: input.createdAt, updatedAt: input.createdAt, diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 5a9deca28d76..16642b244e7f 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -685,15 +685,19 @@ export const layer: Layer.Layer< const sameNativeThread = runningProviderThread.nativeThreadRef?.nativeId === providerThread.nativeThreadRef?.nativeId; - const deliveredItemIds = new Set( - projection.contextHandoffs.flatMap((handoff) => + const settledHandoffs = projection.contextHandoffs.filter( + (handoff) => handoff.toProviderThreadId === providerThread.id && handoff.delivery?.nativeThreadId === runningProviderThread.nativeThreadRef?.nativeId && - handoff.delivery?.status !== "pending" - ? (handoff.delivery?.itemIds ?? []) - : [], - ), + handoff.delivery?.status !== "pending", + ); + const deliveredItemIds = new Set( + settledHandoffs.flatMap((handoff) => handoff.delivery?.itemIds ?? []), ); + const coveredItemIds = new Set([ + ...deliveredItemIds, + ...settledHandoffs.flatMap((handoff) => handoff.delivery?.omittedItemIds ?? []), + ]); // Canonical text is a fallback estimate when a resumed provider supplies no // context telemetry. Native compaction/hidden tool state may differ. const nativeContextBytes = () => @@ -741,7 +745,7 @@ export const layer: Layer.Layer< (item) => item.runId !== null && missedRunIds.has(item.runId) && - !deliveredItemIds.has(item.id) && + !coveredItemIds.has(item.id) && historicalMessage(item) !== null, ); const startWithHandoffs = ( diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index 91941f35fdf5..f420c0859a4d 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -3,6 +3,7 @@ import { historyResponseItems } from "../ContextHandoffBudget.ts"; import { assert, describe, it } from "@effect/vitest"; import { CommandId, + type ChatAttachment, EventId, MessageId, type ModelSelection, @@ -90,6 +91,7 @@ interface CapturedTurn { readonly threadId: ThreadId; readonly providerThreadId: ProviderThreadId; readonly text: string; + readonly attachments: ReadonlyArray; } function unimplemented(driver: ProviderDriverKind, detail: string) { @@ -216,6 +218,7 @@ function makeTestAdapter(input: { threadId: turnInput.threadId, providerThreadId: turnInput.providerThread.id, text: turnInput.message.text, + attachments: turnInput.message.attachments, }, ]); if ( @@ -350,6 +353,8 @@ describe("orchestration v2 provider switching", () => { "compact-fallback", "compact-legacy", "large-current-input", + "screenshot-native", + "screenshot-fallback", "delivery-write-failure", ] as const) { it.live(`preserves handoffs through ${scenario}`, () => @@ -374,7 +379,9 @@ describe("orchestration v2 provider switching", () => { modelSelection: CLAUDE_MODEL_SELECTION, responseByRunOrdinal: {}, capturedTurns, - ...(scenario === "compact-native" || scenario === "large-current-input" + ...(scenario === "compact-native" || + scenario === "large-current-input" || + scenario === "screenshot-native" ? { injectedHistory } : {}), }), @@ -383,6 +390,13 @@ describe("orchestration v2 provider switching", () => { const orchestrator = yield* OrchestratorV2; const worker = yield* OrchestrationEffectWorkerV2; const eventSink = yield* EventSinkV2; + const screenshot: ChatAttachment = { + type: "image", + id: "screenshot", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 100_000, + }; const dispatch = (ordinal: number, text: string, selection: ModelSelection) => orchestrator.dispatch({ type: "message.dispatch", @@ -392,7 +406,7 @@ describe("orchestration v2 provider switching", () => { createdBy: "user", creationSource: "web", text, - attachments: [], + attachments: scenario.startsWith("screenshot") && ordinal === 2 ? [screenshot] : [], modelSelection: selection, dispatchMode: { type: "start_immediately" }, }); @@ -461,6 +475,9 @@ describe("orchestration v2 provider switching", () => { const projection = yield* orchestrator.getThreadProjection(threadId); assert.equal(projection.runs.at(-1)?.status, "completed"); const handoff = projection.contextHandoffs.at(-1)!; + if (scenario.startsWith("screenshot")) { + assert.deepEqual((yield* Ref.get(capturedTurns)).at(-1)!.attachments, [screenshot]); + } if (scenario === "compact-fallback" || scenario === "compact-legacy") { if (scenario === "compact-legacy") { const { history: _history, ...legacyHandoff } = handoff; @@ -489,8 +506,14 @@ describe("orchestration v2 provider switching", () => { ?.status, "inline", ); - } else if (scenario === "delivery-write-failure") { - assert.equal(handoff.delivery?.status, "pending"); + } else if ( + scenario === "delivery-write-failure" || + scenario === "screenshot-fallback" + ) { + assert.equal( + handoff.delivery?.status, + scenario === "screenshot-fallback" ? "inline" : "pending", + ); assert.include( (yield* Ref.get(capturedTurns)).at(-1)!.text, "Original request with constraints", @@ -578,10 +601,18 @@ describe("orchestration v2 provider switching", () => { ({ event }) => event.type === "run.updated" && event.payload.ordinal === ordinal && - event.payload.status === status, + (event.payload.status === "completed" || event.payload.status === "failed"), ), Stream.runHead, Effect.andThen(worker.drain()), + Effect.andThen( + Effect.gen(function* () { + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status, + status, + ); + }), + ), ); yield* orchestrator.dispatch({ type: "thread.create", @@ -623,8 +654,15 @@ describe("orchestration v2 provider switching", () => { const delta = yield* encodeJson( (yield* Ref.get(injectedHistory)).slice(historyBeforeRetry), ); - if (failure === "large-missed-request") assert.include(delta, "omitted 1 items"); - else assert.include(delta, "First target request"); + if (failure === "large-missed-request") { + assert.include(delta, "omitted 1 items"); + const missedRequest = retried.turnItems.find( + (item) => item.type === "user_message" && item.text === "x".repeat(7_000), + )!; + const delivery = retried.contextHandoffs.at(-1)!.delivery!; + assert.include(delivery.omittedItemIds ?? [], missedRequest.id); + assert.notInclude(delivery.itemIds, missedRequest.id); + } else assert.include(delta, "First target request"); assert.notInclude(delta, "Original request with constraints"); assert.notInclude(delta, "Original partial work"); assert.equal(yield* Ref.get(generation), 1); @@ -641,6 +679,16 @@ describe("orchestration v2 provider switching", () => { assert.include(newHistory, "Original partial work"); assert.notInclude(newHistory, "Retry target request"); } + const beforeFollowup = yield* Ref.get(injectedHistory); + const followup = "z".repeat(6_000); + yield* dispatch(4, followup, CLAUDE_MODEL_SELECTION); + yield* wait(4, "completed"); + assert.equal((yield* Ref.get(capturedTurns)).at(-1)!.text, followup); + assert.deepEqual(yield* Ref.get(injectedHistory), beforeFollowup); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).contextHandoffs.length, + retried.contextHandoffs.length, + ); }).pipe( Effect.provide( makeOrchestratorV2ReplayLayerWithRegistry( diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index cd0b7ce11d68..6d2f9d8145f2 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -710,6 +710,8 @@ export const OrchestrationV2ContextHandoff = Schema.Struct({ messages: Schema.Array(OrchestrationV2HistoricalMessage), coverage: Schema.String, omittedItems: NonNegativeInt, + // IDs omitted during preparation, before the target's delivery budget is known. + omittedItemIds: Schema.optional(Schema.Array(TurnItemId)), }), ), delivery: Schema.optional( @@ -717,6 +719,8 @@ export const OrchestrationV2ContextHandoff = Schema.Struct({ nativeThreadId: Schema.String, status: Schema.Literals(["pending", "injected", "inline"]), itemIds: Schema.Array(TurnItemId), + // Covered by recovery instructions, but not present in native model history. + omittedItemIds: Schema.optional(Schema.Array(TurnItemId)), }), ), detailInTurnItem: Schema.optional(Schema.Literal(true)), From 159fc0237af01292618a48ab24408cbaaa930b93 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 18 Sep 2026 14:38:39 -0700 Subject: [PATCH 08/13] fix(server): budget handoffs against selected model capacity --- .../Adapters/ClaudeAdapterV2.ts | 3 + .../ContextHandoffBudget.test.ts | 43 ++++++ .../orchestration-v2/ContextHandoffBudget.ts | 11 +- .../src/orchestration-v2/ProviderAdapter.ts | 2 + .../ProviderTurnStartService.ts | 39 ++++-- .../ProviderSwitch.integration.test.ts | 122 ++++++++++++++++-- .../src/provider/ClaudeModelCatalog.test.ts | 54 +++++++- .../server/src/provider/ClaudeModelCatalog.ts | 2 +- docs/user/portable-handoffs.md | 23 +++- 9 files changed, 262 insertions(+), 37 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 4291d9861b61..2becedbc87ee 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -89,6 +89,7 @@ import { import { BUNDLED_CLAUDE_MODEL_CATALOG, resolveClaudeCatalogContextWindow, + resolveClaudeCatalogContextWindowTokens, } from "../../provider/ClaudeModelCatalog.ts"; import { boundProviderEventForLogging, @@ -5962,6 +5963,8 @@ export function makeClaudeAdapterV2( driver: CLAUDE_PROVIDER, providerSessionId: input.providerSessionId, providerSession: session, + getModelContextWindow: (selection) => + resolveClaudeCatalogContextWindowTokens(BUNDLED_CLAUDE_MODEL_CATALOG, selection), events: Stream.fromEffectRepeat(Queue.take(events)), hasPendingBackgroundWork: Effect.gen(function* () { // Session capability: any native thread with pending work pins idle. diff --git a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts index 5b21c9650ea1..69328a4d711b 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts @@ -2,6 +2,7 @@ import type { ProviderAdapterV2HistoricalContext } from "./ProviderAdapter.ts"; import { assert, describe, it } from "@effect/vitest"; import { ContextHandoffId, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ProviderDriverKind, ProviderInstanceId, ProviderThreadId, @@ -213,6 +214,7 @@ describe("handoff budget", () => { attachments: [], providerThread, nativeContextBytes: 0, + modelContextWindow: 32_000, }; assert.isBelow(handoffBudget(base), 16_000); assert.isBelow(handoffBudget({ ...base, nativeContextBytes: 8_000 }), handoffBudget(base)); @@ -245,6 +247,7 @@ describe("handoff budget", () => { handoffBudget({ ...base, tokenCap: 64_000, + modelContextWindow: 1_000_000, // The history byte cap is independent of both current text and image payloads. userText: "x".repeat(70_000), attachments, @@ -258,6 +261,46 @@ describe("handoff budget", () => { } assert.equal(handoffBudget({ ...base, tokenCap: 2_000 }), 2_000); }); + it("budgets all supported image counts with unknown capacity, honoring smaller known windows", () => { + for (let count = 1; count <= PROVIDER_SEND_TURN_MAX_ATTACHMENTS; count++) { + const input = { + tokenCap: 16_000, + userText: "Compare these screenshots", + providerThread, + nativeContextBytes: 0, + attachments: Array.from({ length: count }, (_, index) => ({ + type: "image", + id: `image-${index}`, + name: "image.png", + mimeType: "image/png", + sizeBytes: 10 * 1024 * 1024, + })), + }; + const budget = handoffBudget(input); + assert.equal(budget, 16_000); + const selected = selectHistory({ messages, coverage: "Recover omitted history", budget }); + assert.isAtMost(historyCost(selected.messages, selected.context), budget); + assert.equal(handoffBudget({ ...input, modelContextWindow: 20_000 }), 0); + assert.equal( + handoffBudget({ + ...input, + providerThread: { ...providerThread, contextUsage: { usedTokens: 0, maxTokens: 20_000 } }, + }), + 0, + ); + assert.equal( + handoffBudget({ + ...input, + modelContextWindow: 1_000_000, + providerThread: { + ...providerThread, + contextUsage: { usedTokens: 0, maxTokens: 1_000_000, autoCompactThreshold: 20_000 }, + }, + }), + 0, + ); + } + }); }); describe("handoff delivery", () => { diff --git a/apps/server/src/orchestration-v2/ContextHandoffBudget.ts b/apps/server/src/orchestration-v2/ContextHandoffBudget.ts index 7c6471009e73..ee6c30650ed1 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffBudget.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffBudget.ts @@ -17,17 +17,22 @@ export const handoffTokenCapConfig = Config.Int("T3CODE_CONTEXT_HANDOFF_TOKEN_CA // One UTF-8 byte per token is deliberately pessimistic for byte-based tokenizers, // including multilingual text. It is not a tokenizer or a guarantee for arbitrary -// custom models. Unknown windows use a 32k allowance, with half reserved for tools, -// instructions and subsequent work. Current input is never truncated. +// custom models. Unknown windows use a 128k allowance, reserving a quarter for +// tools, instructions and subsequent work. Current input is never truncated. export function handoffBudget(input: { readonly tokenCap: number; readonly userText: string; readonly attachments: ReadonlyArray; readonly providerThread: OrchestrationV2ProviderThread; readonly nativeContextBytes: number; + readonly modelContextWindow?: number | undefined; }): number { const usage = input.providerThread.contextUsage; - const window = Math.min(usage?.maxTokens ?? 32_000, usage?.autoCompactThreshold ?? Infinity); + const window = Math.min( + input.modelContextWindow ?? usage?.maxTokens ?? 128_000, + usage?.maxTokens ?? Infinity, + usage?.autoCompactThreshold ?? Infinity, + ); const native = usage?.usedTokens ?? input.nativeContextBytes; // Encoded image bytes are not model tokens. Without dimensions/detail metadata, // reserve 8k tokens per image, above typical resized Codex/Claude image costs. diff --git a/apps/server/src/orchestration-v2/ProviderAdapter.ts b/apps/server/src/orchestration-v2/ProviderAdapter.ts index 0689a266b556..6131232427ab 100644 --- a/apps/server/src/orchestration-v2/ProviderAdapter.ts +++ b/apps/server/src/orchestration-v2/ProviderAdapter.ts @@ -507,6 +507,8 @@ export interface ProviderAdapterV2SessionRuntime { readonly hasPendingBackgroundWorkForThread?: ( providerThread: OrchestrationV2ProviderThread, ) => Effect.Effect; + /** Capacity for the requested model/options, independent of native thread usage. */ + readonly getModelContextWindow?: (modelSelection: ModelSelection) => number | undefined; readonly ensureThread: ( input: ProviderAdapterV2EnsureThreadInput, ) => Effect.Effect; diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 16642b244e7f..63eafb8cf6b2 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -1,3 +1,4 @@ +import { modelSelectionsEqual } from "@t3tools/shared/model"; import { projectComposerContextForProvider } from "@t3tools/shared/composerContextReferences"; import { CommandId, @@ -552,8 +553,22 @@ export const layer: Layer.Layer< return; } const now = yield* DateTime.now; + // Only started runs reached the provider-thread update below. Queued runs and + // failures during session setup cannot establish a new telemetry selection. + const previousSelection = projection.runs.findLast( + (source) => + source.ordinal < run.ordinal && + source.startedAt !== null && + source.providerThreadId === providerThread.id, + )?.modelSelection; + const sameSelection = + previousSelection === undefined || + modelSelectionsEqual(previousSelection, run.modelSelection); const runningProviderThread: OrchestrationV2ProviderThread = { ...loadedProviderThread, + // Persist invalidation before delivery: a failed start must not let the next + // attempt mistake old-model telemetry for usage of the new selection. + contextUsage: sameSelection ? (loadedProviderThread.contextUsage ?? null) : null, id: providerThread.id, driver: session.driver, providerInstanceId: run.providerInstanceId, @@ -715,18 +730,17 @@ export const layer: Layer.Layer< return sum + (historical === null ? 0 : Buffer.byteLength(historical.text)); }, 0) : 0; - const previousModel = projection.runs.findLast( - (source) => source.id !== run.id && source.providerThreadId === providerThread.id, - )?.modelSelection.model; - const budgetProviderThread = sameNativeThread - ? { - ...providerThread, - contextUsage: - previousModel !== undefined && previousModel !== run.modelSelection.model - ? null - : providerThread.contextUsage, - } - : { ...runningProviderThread, contextUsage: null }; + const reportedUsage = sameSelection + ? (runningProviderThread.contextUsage ?? providerThread.contextUsage) + : undefined; + const modelContextWindow = + session.getModelContextWindow?.(run.modelSelection) ?? reportedUsage?.maxTokens; + // Replacing a native thread clears its usage, not the selected model's capacity. + // Model/options changes invalidate old window and compaction telemetry. + const budgetProviderThread = { + ...runningProviderThread, + contextUsage: sameNativeThread ? (reportedUsage ?? null) : null, + }; const deliveredAttemptIds = new Set( projection.providerTurns.map((turn) => turn.runAttemptId), ); @@ -783,6 +797,7 @@ export const layer: Layer.Layer< providerThread: runningProviderThread, budget: handoffBudget({ tokenCap, + modelContextWindow, userText, attachments: message.attachments, providerThread: budgetProviderThread, diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index f420c0859a4d..33c8fc8ef2f6 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -19,6 +19,7 @@ import { ThreadId, TurnItemId, ProviderDriverKind, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -111,6 +112,8 @@ function makeTestAdapter(input: { readonly failInjectionOnce?: Ref.Ref; readonly nativeThreadGeneration?: Ref.Ref; readonly failResume?: boolean; + readonly initialContextUsage?: OrchestrationV2ProviderThread["contextUsage"]; + readonly getModelContextWindow?: (selection: ModelSelection) => number | undefined; readonly failedRunOrdinals?: ReadonlySet; readonly interruptedRunOrdinals?: ReadonlySet; readonly holdRunOrdinal?: number; @@ -145,6 +148,9 @@ function makeTestAdapter(input: { providerSessionId: sessionInput.providerSessionId, providerSession, events: Stream.fromPubSub(events), + ...(input.getModelContextWindow === undefined + ? {} + : { getModelContextWindow: input.getModelContextWindow }), ensureThread: (threadInput) => Effect.gen(function* () { const createdAt = yield* DateTime.now; @@ -170,6 +176,7 @@ function makeTestAdapter(input: { firstRunOrdinal: null, lastRunOrdinal: null, handoffIds: [], + contextUsage: input.initialContextUsage ?? null, forkedFrom: null, createdAt, updatedAt: createdAt, @@ -355,6 +362,16 @@ describe("orchestration v2 provider switching", () => { "large-current-input", "screenshot-native", "screenshot-fallback", + "screenshot-pair-native", + "screenshot-pair-fallback", + "screenshot-eight-native", + "screenshot-eight-fallback", + "screenshot-large-model-native", + "screenshot-reported-capacity-native", + "screenshot-model-change-native", + "screenshot-option-change-native", + "screenshot-model-change-retry-native", + "screenshot-option-change-retry-native", "delivery-write-failure", ] as const) { it.live(`preserves handoffs through ${scenario}`, () => @@ -363,6 +380,18 @@ describe("orchestration v2 provider switching", () => { const cwd = yield* checkpointWorkspace(`handoff-${scenario}`); const capturedTurns = yield* Ref.make>([]); const injectedHistory = yield* Ref.make>([]); + const modelScenario = scenario.includes("model") || scenario.includes("option-change"); + const failStartOnce = yield* Ref.make(false); + const capacityScenario = modelScenario || scenario.includes("reported-capacity"); + const returning = scenario === "large-current-input" || scenario.includes("-change-"); + const targetSelection: ModelSelection = !modelScenario + ? CLAUDE_MODEL_SELECTION + : { + ...CLAUDE_MODEL_SELECTION, + ...(scenario.includes("option-change") + ? { options: [{ id: "contextWindow", value: "1m" }] } + : { model: `${CLAUDE_MODEL_SELECTION.model}-large` }), + }; const registry = makeProviderAdapterRegistryLayer([ makeTestAdapter({ instanceId: CODEX_MODEL_SELECTION.instanceId, @@ -379,9 +408,24 @@ describe("orchestration v2 provider switching", () => { modelSelection: CLAUDE_MODEL_SELECTION, responseByRunOrdinal: {}, capturedTurns, - ...(scenario === "compact-native" || - scenario === "large-current-input" || - scenario === "screenshot-native" + ...(scenario.includes("retry") ? { failStartOnce } : {}), + ...(scenario.includes("reported-capacity") + ? { initialContextUsage: { usedTokens: 999_999, maxTokens: 1_000_000 } } + : {}), + ...(modelScenario + ? { + getModelContextWindow: (selection: ModelSelection) => + selection.model.endsWith("-large") || + selection.options?.some( + (option) => option.id === "contextWindow" && option.value === "1m", + ) + ? 1_000_000 + : 32_000, + } + : scenario === "large-current-input" + ? { getModelContextWindow: () => 32_000 } + : {}), + ...(scenario.endsWith("-native") || scenario === "large-current-input" ? { injectedHistory } : {}), }), @@ -397,6 +441,18 @@ describe("orchestration v2 provider switching", () => { mimeType: "image/png", sizeBytes: 100_000, }; + const screenshots = Array.from( + { + length: + scenario.includes("eight") || capacityScenario + ? PROVIDER_SEND_TURN_MAX_ATTACHMENTS + : scenario.includes("pair") + ? 2 + : 1, + }, + (_, index) => ({ ...screenshot, id: `screenshot-${index}` }), + ); + const targetOrdinal = returning ? 4 : 2; const dispatch = (ordinal: number, text: string, selection: ModelSelection) => orchestrator.dispatch({ type: "message.dispatch", @@ -406,7 +462,8 @@ describe("orchestration v2 provider switching", () => { createdBy: "user", creationSource: "web", text, - attachments: scenario.startsWith("screenshot") && ordinal === 2 ? [screenshot] : [], + attachments: + scenario.startsWith("screenshot") && ordinal >= targetOrdinal ? screenshots : [], modelSelection: selection, dispatchMode: { type: "start_immediately" }, }); @@ -458,25 +515,63 @@ describe("orchestration v2 provider switching", () => { yield* Effect.addFinalizer(() => Effect.sync(() => spy?.mockRestore())); const current = scenario.startsWith("compact") ? "/compact" - : scenario === "large-current-input" - ? "x".repeat(9_000) - : "Continue work"; + : capacityScenario + ? "x".repeat(70_000) + : scenario === "large-current-input" + ? "x".repeat(9_000) + : "Continue work"; // First establish the returning native thread: the current request must // not be charged as existing context on the subsequent handoff. - if (scenario === "large-current-input") { + if (returning) { yield* dispatch(2, "Establish target", CLAUDE_MODEL_SELECTION); yield* wait(2); + if (modelScenario) { + const target = (yield* orchestrator.getThreadProjection( + threadId, + )).providerThreads.find( + (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, + )!; + yield* eventSink.write({ + events: [ + { + id: EventId.make("old-model-usage"), + type: "provider-thread.updated", + threadId, + occurredAt: yield* DateTime.now, + payload: { + ...target, + contextUsage: { + usedTokens: 30_000, + maxTokens: 32_000, + autoCompactThreshold: 31_000, + }, + }, + }, + ], + }); + } yield* dispatch(3, "New source constraint", CODEX_MODEL_SELECTION); yield* wait(3); } - const targetOrdinal = scenario === "large-current-input" ? 4 : 2; - yield* dispatch(targetOrdinal, current, CLAUDE_MODEL_SELECTION); + if (scenario.includes("retry")) yield* Ref.set(failStartOnce, true); + yield* dispatch(targetOrdinal, current, targetSelection); yield* wait(targetOrdinal); + if (scenario.includes("retry")) { + const failed = yield* orchestrator.getThreadProjection(threadId); + assert.equal(failed.runs.at(-1)?.status, "failed"); + assert.isNull( + failed.providerThreads.find( + (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, + )!.contextUsage, + ); + yield* dispatch(targetOrdinal + 1, current, targetSelection); + yield* wait(targetOrdinal + 1); + } const projection = yield* orchestrator.getThreadProjection(threadId); assert.equal(projection.runs.at(-1)?.status, "completed"); const handoff = projection.contextHandoffs.at(-1)!; if (scenario.startsWith("screenshot")) { - assert.deepEqual((yield* Ref.get(capturedTurns)).at(-1)!.attachments, [screenshot]); + assert.deepEqual((yield* Ref.get(capturedTurns)).at(-1)!.attachments, screenshots); } if (scenario === "compact-fallback" || scenario === "compact-legacy") { if (scenario === "compact-legacy") { @@ -508,11 +603,11 @@ describe("orchestration v2 provider switching", () => { ); } else if ( scenario === "delivery-write-failure" || - scenario === "screenshot-fallback" + (scenario.startsWith("screenshot") && scenario.endsWith("fallback")) ) { assert.equal( handoff.delivery?.status, - scenario === "screenshot-fallback" ? "inline" : "pending", + scenario.startsWith("screenshot") ? "inline" : "pending", ); assert.include( (yield* Ref.get(capturedTurns)).at(-1)!.text, @@ -574,6 +669,7 @@ describe("orchestration v2 provider switching", () => { capturedTurns, injectedHistory, nativeThreadGeneration: generation, + getModelContextWindow: () => 32_000, ...(failure !== "injection" ? { failStartOnce: failOnce } : { failInjectionOnce: failOnce }), diff --git a/apps/server/src/provider/ClaudeModelCatalog.test.ts b/apps/server/src/provider/ClaudeModelCatalog.test.ts index d5c9d8f53d1e..c0384e102b8a 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.test.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.test.ts @@ -7,6 +7,7 @@ import { formatClaudeVersionUpgradeMessage, normalizeClaudeCatalogEffort, resolveClaudeCatalogApiModelId, + resolveClaudeCatalogContextWindowTokens, resolveClaudeCatalogEffort, resolveClaudeModelCatalog, resolveClaudeModelsForVersion, @@ -40,7 +41,10 @@ const manifest = (): ModelManifestData => ({ id: "contextWindow", label: "Context Window", type: "select", - options: [{ id: "large", label: "Large", isDefault: true }], + options: [ + { id: "large", label: "Large", isDefault: true }, + { id: "small", label: "Small" }, + ], }, ], }, @@ -67,6 +71,54 @@ const manifest = (): ModelManifestData => ({ }); describe("Claude model catalog", () => { + it("resolves capacity from selected options and fixed catalog windows without guessing custom models", () => { + const source = manifest(); + const profile = source.providers!.claudeAgent!.profiles.synthetic!; + const catalog = resolveClaudeModelCatalog({ + ...source, + providers: { + claudeAgent: { + ...source.providers!.claudeAgent!, + profiles: { + fixed: { + capabilities: { optionDescriptors: [] }, + adapter: { claudeCode: { fixedContextWindowTokens: 64_000 } }, + }, + synthetic: { + ...profile, + adapter: { claudeCode: { contextWindowTokens: { large: 1_000_000, small: 32_000 } } }, + }, + }, + models: [ + ...source.providers!.claudeAgent!.models, + { + slug: "fixed", + name: "Fixed", + status: "current", + profile: "fixed", + }, + ], + }, + }, + }); + const selection = { instanceId: ProviderInstanceId.make("claudeAgent"), model: "synthetic" }; + assert.equal(resolveClaudeCatalogContextWindowTokens(catalog, selection), 1_000_000); + assert.equal( + resolveClaudeCatalogContextWindowTokens(catalog, { + ...selection, + options: [{ id: "contextWindow", value: "small" }], + }), + 32_000, + ); + assert.equal( + resolveClaudeCatalogContextWindowTokens(catalog, { ...selection, model: "fixed" }), + 64_000, + ); + assert.isUndefined( + resolveClaudeCatalogContextWindowTokens(catalog, { ...selection, model: "custom" }), + ); + }); + it("filters models at runtime-version boundaries and derives the upgrade message", () => { const catalog = resolveClaudeModelCatalog(manifest()); assert.deepStrictEqual(resolveClaudeModelsForVersion(catalog, "3.1.9"), []); diff --git a/apps/server/src/provider/ClaudeModelCatalog.ts b/apps/server/src/provider/ClaudeModelCatalog.ts index 542b12dab57e..1fb80cb4a57b 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.ts @@ -249,7 +249,7 @@ export function resolveClaudeCatalogApiModelId( return slug; } -function resolveClaudeCatalogContextWindowTokens( +export function resolveClaudeCatalogContextWindowTokens( catalog: ClaudeModelCatalog, modelSelection: ModelSelection | undefined, ): number | undefined { diff --git a/docs/user/portable-handoffs.md b/docs/user/portable-handoffs.md index 3e711db3edf5..e2d84458ed93 100644 --- a/docs/user/portable-handoffs.md +++ b/docs/user/portable-handoffs.md @@ -26,10 +26,19 @@ model before trying again. Server operators can set `T3CODE_CONTEXT_HANDOFF_TOKEN_CAP` to change the initial history allowance (default 16,000; clamped to 1,024โ€“64,000). This is an upper bound, not a provider context-window -guarantee. Accounting conservatively charges one token per UTF-8 byte, including attribution and -JSON escaping, and caps handoff plus current-input accounting at 64,000 bytes. Attachments reserve -at least 4,096 units each, or their byte size when larger. - -When provider context telemetry is unavailable, T3 Code assumes a 32,000-token window, reserves at -least 16,000 for instructions, tools, and work, and estimates existing context from saved activity. -Custom models and hidden native context can differ, so the provider may still reject an input. +guarantee. Text accounting conservatively charges one token per UTF-8 byte, including attribution +and JSON escaping. Imported history has a separate 64,000-byte ceiling; your current input and +attachment payloads do not consume that ceiling. + +T3 Code uses available capacity information for your selected model and options, together with +provider context telemetry. Starting a fresh provider conversation clears prior usage, while keeping +known model capacity. Changing models or context-window options discards stale usage and compaction +thresholds. + +When no capacity information is available, T3 Code assumes a 128,000-token window. It reserves at +least 16,000 tokens, or a quarter of the window when larger, for instructions, tools, and subsequent +work. Images reserve an estimated 8,192 tokens each, independent of their file size; other +attachments reserve 4,096 each for their references. Existing context is estimated from saved +activity when usage telemetry is unavailable. Known smaller windows still constrain the handoff. +These are fallback estimates, not exact token counts. Image resolution, custom models, and hidden +native context can differ, so the provider may still reject an input. From beb43dc7936f50a861c6593e18281940c8af78bd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 18 Sep 2026 14:57:25 -0700 Subject: [PATCH 09/13] fix(server): account for prior native attachments in handoffs --- .../ContextHandoffBudget.test.ts | 6 +- .../orchestration-v2/ContextHandoffBudget.ts | 30 +-- .../ProviderTurnStartService.ts | 70 ++++++- .../ProviderSwitch.integration.test.ts | 191 +++++++++++++++++- packages/contracts/src/orchestrationV2.ts | 2 + 5 files changed, 264 insertions(+), 35 deletions(-) diff --git a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts index 69328a4d711b..de1a9cc91df6 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts @@ -213,11 +213,11 @@ describe("handoff budget", () => { userText: "Continue", attachments: [], providerThread, - nativeContextBytes: 0, + nativeContextEstimate: 0, modelContextWindow: 32_000, }; assert.isBelow(handoffBudget(base), 16_000); - assert.isBelow(handoffBudget({ ...base, nativeContextBytes: 8_000 }), handoffBudget(base)); + assert.isBelow(handoffBudget({ ...base, nativeContextEstimate: 8_000 }), handoffBudget(base)); assert.equal(handoffBudget({ ...base, userText: "็•Œ".repeat(30_000) }), 0); assert.equal( handoffBudget({ @@ -267,7 +267,7 @@ describe("handoff budget", () => { tokenCap: 16_000, userText: "Compare these screenshots", providerThread, - nativeContextBytes: 0, + nativeContextEstimate: 0, attachments: Array.from({ length: count }, (_, index) => ({ type: "image", id: `image-${index}`, diff --git a/apps/server/src/orchestration-v2/ContextHandoffBudget.ts b/apps/server/src/orchestration-v2/ContextHandoffBudget.ts index ee6c30650ed1..31f945509580 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffBudget.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffBudget.ts @@ -15,6 +15,19 @@ export const handoffTokenCapConfig = Config.Int("T3CODE_CONTEXT_HANDOFF_TOKEN_CA Config.map((value) => Math.max(1_024, Math.min(HANDOFF_BYTE_CAP, value))), ); +export function attachmentTokenAllowance(attachments: ReadonlyArray): number { + // Encoded image bytes are not model tokens. Without dimensions/detail metadata, + // reserve 8k tokens per image, above typical resized Codex/Claude image costs. + // This is a fallback estimate, not a bound for original-resolution/custom models. + // https://developers.openai.com/api/docs/guides/image-cost-calculator + // https://platform.claude.com/docs/en/build-with-claude/vision + // Other attachments are path references; reserve space for their descriptors. + return attachments.reduce( + (sum, attachment) => sum + (attachment.type === "image" ? 8_192 : 4_096), + 0, + ); +} + // One UTF-8 byte per token is deliberately pessimistic for byte-based tokenizers, // including multilingual text. It is not a tokenizer or a guarantee for arbitrary // custom models. Unknown windows use a 128k allowance, reserving a quarter for @@ -24,7 +37,7 @@ export function handoffBudget(input: { readonly userText: string; readonly attachments: ReadonlyArray; readonly providerThread: OrchestrationV2ProviderThread; - readonly nativeContextBytes: number; + readonly nativeContextEstimate: number; readonly modelContextWindow?: number | undefined; }): number { const usage = input.providerThread.contextUsage; @@ -33,18 +46,9 @@ export function handoffBudget(input: { usage?.maxTokens ?? Infinity, usage?.autoCompactThreshold ?? Infinity, ); - const native = usage?.usedTokens ?? input.nativeContextBytes; - // Encoded image bytes are not model tokens. Without dimensions/detail metadata, - // reserve 8k tokens per image, above typical resized Codex/Claude image costs. - // This is a fallback estimate, not a bound for original-resolution/custom models. - // https://developers.openai.com/api/docs/guides/image-cost-calculator - // https://platform.claude.com/docs/en/build-with-claude/vision - // Other attachments are path references; reserve space for their descriptors. - const attachmentAllowance = input.attachments.reduce( - (sum, attachment) => sum + (attachment.type === "image" ? 8_192 : 4_096), - 0, - ); - const current = Buffer.byteLength(JSON.stringify(input.userText)) + attachmentAllowance; + const native = usage?.usedTokens ?? input.nativeContextEstimate; + const current = + Buffer.byteLength(JSON.stringify(input.userText)) + attachmentTokenAllowance(input.attachments); return Math.max( 0, Math.min( diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 63eafb8cf6b2..0de736376452 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -29,6 +29,7 @@ import { DEFAULT_HANDOFF_TOKEN_CAP, handoffTokenCapConfig, handoffBudget, + attachmentTokenAllowance, historicalMessage, } from "./ContextHandoffBudget.ts"; import { deliverContextHandoffs } from "./ContextHandoffDelivery.ts"; @@ -590,6 +591,9 @@ export const layer: Layer.Layer< }; const runningAttempt: OrchestrationV2RunAttempt = { ...attempt, + ...(runningProviderThread.nativeThreadRef?.nativeId == null + ? {} + : { nativeThreadId: runningProviderThread.nativeThreadRef.nativeId }), status: "running", startedAt: now, }; @@ -713,9 +717,46 @@ export const layer: Layer.Layer< ...deliveredItemIds, ...settledHandoffs.flatMap((handoff) => handoff.delivery?.omittedItemIds ?? []), ]); - // Canonical text is a fallback estimate when a resumed provider supplies no - // context telemetry. Native compaction/hidden tool state may differ. - const nativeContextBytes = () => + const deliveredAttemptIds = new Set( + projection.providerTurns.map((turn) => turn.runAttemptId), + ); + const acceptedAttempts = projection.attempts.filter( + (source) => + source.providerThreadId === providerThread.id && deliveredAttemptIds.has(source.id), + ); + const nativeInputRunIds = new Set( + acceptedAttempts + .filter( + (source) => + source.nativeThreadId !== undefined && + source.nativeThreadId === runningProviderThread.nativeThreadRef?.nativeId, + ) + .map((source) => source.runId), + ); + const legacyInputRunIds = new Set( + acceptedAttempts + .filter((source) => source.nativeThreadId === undefined) + .map((source) => source.runId), + ); + const legacyRecoveredRunIds = new Set( + projection.runs + .filter( + (source) => + source.providerThreadId === providerThread.id && + settledHandoffs.some( + (handoff) => + handoff.strategy === "full_thread_summary" && + handoff.fromProviderThreadIds.includes(providerThread.id) && + source.ordinal >= handoff.coveredRunOrdinals.from && + source.ordinal <= handoff.coveredRunOrdinals.to, + ), + ) + .map((source) => source.id), + ); + // Use saved text and actual native attachments when telemetry is absent. + // Legacy attempts lack native identity; exclude their explicitly recovered + // history, whose attachments were not replayed into the replacement thread. + const nativeContextEstimate = () => sameNativeThread ? projection.turnItems.reduce((sum, item) => { if ( @@ -727,7 +768,21 @@ export const layer: Layer.Layer< ) return sum; const historical = historicalMessage(item); - return sum + (historical === null ? 0 : Buffer.byteLength(historical.text)); + const nativeAttachments = + item.type === "user_message" && + item.providerThreadId === providerThread.id && + item.runId !== null && + (nativeInputRunIds.has(item.runId) || + (legacyInputRunIds.has(item.runId) && + !coveredItemIds.has(item.id) && + !legacyRecoveredRunIds.has(item.runId))) + ? attachmentTokenAllowance(item.attachments) + : 0; + return ( + sum + + (historical === null ? 0 : Buffer.byteLength(historical.text)) + + nativeAttachments + ); }, 0) : 0; const reportedUsage = sameSelection @@ -741,9 +796,6 @@ export const layer: Layer.Layer< ...runningProviderThread, contextUsage: sameNativeThread ? (reportedUsage ?? null) : null, }; - const deliveredAttemptIds = new Set( - projection.providerTurns.map((turn) => turn.runAttemptId), - ); const missedRuns = projection.runs.filter( (source) => source.ordinal < run.ordinal && @@ -801,9 +853,9 @@ export const layer: Layer.Layer< userText, attachments: message.attachments, providerThread: budgetProviderThread, - nativeContextBytes: + nativeContextEstimate: budgetProviderThread.contextUsage?.usedTokens === undefined - ? nativeContextBytes() + ? nativeContextEstimate() : 0, }), alreadyDeliveredItemIds: deliveredItemIds, diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index 33c8fc8ef2f6..f20fc4fff99b 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -112,6 +112,7 @@ function makeTestAdapter(input: { readonly failInjectionOnce?: Ref.Ref; readonly nativeThreadGeneration?: Ref.Ref; readonly failResume?: boolean; + readonly failResumeOnce?: Ref.Ref; readonly initialContextUsage?: OrchestrationV2ProviderThread["contextUsage"]; readonly getModelContextWindow?: (selection: ModelSelection) => number | undefined; readonly failedRunOrdinals?: ReadonlySet; @@ -183,9 +184,15 @@ function makeTestAdapter(input: { } satisfies OrchestrationV2ProviderThread; }), resumeThread: ({ providerThread }) => - input.failResume - ? unimplemented(input.driver, "simulated native resume failure") - : Effect.succeed(providerThread), + Effect.gen(function* () { + if ( + input.failResume || + (input.failResumeOnce !== undefined && + (yield* Ref.getAndSet(input.failResumeOnce, false))) + ) + return yield* unimplemented(input.driver, "simulated native resume failure"); + return providerThread; + }), ...(input.injectedHistory === undefined ? {} : { @@ -372,6 +379,14 @@ describe("orchestration v2 provider switching", () => { "screenshot-option-change-native", "screenshot-model-change-retry-native", "screenshot-option-change-retry-native", + "prior-images-native", + "prior-images-fallback", + "imported-prior-images-native", + "imported-prior-images-fallback", + "prior-images-telemetry-native", + "prior-images-unsent-native", + "prior-images-replacement-native", + "prior-images-legacy-replacement-native", "delivery-write-failure", ] as const) { it.live(`preserves handoffs through ${scenario}`, () => @@ -382,8 +397,13 @@ describe("orchestration v2 provider switching", () => { const injectedHistory = yield* Ref.make>([]); const modelScenario = scenario.includes("model") || scenario.includes("option-change"); const failStartOnce = yield* Ref.make(false); + const failResumeOnce = yield* Ref.make(false); + const generation = yield* Ref.make(0); + const priorImages = scenario.includes("prior-images"); + const replaceNative = scenario.includes("replacement"); const capacityScenario = modelScenario || scenario.includes("reported-capacity"); - const returning = scenario === "large-current-input" || scenario.includes("-change-"); + const returning = + scenario === "large-current-input" || scenario.includes("-change-") || priorImages; const targetSelection: ModelSelection = !modelScenario ? CLAUDE_MODEL_SELECTION : { @@ -408,7 +428,10 @@ describe("orchestration v2 provider switching", () => { modelSelection: CLAUDE_MODEL_SELECTION, responseByRunOrdinal: {}, capturedTurns, - ...(scenario.includes("retry") ? { failStartOnce } : {}), + ...(scenario.includes("retry") || scenario.includes("unsent") + ? { failStartOnce } + : {}), + ...(replaceNative ? { failResumeOnce, nativeThreadGeneration: generation } : {}), ...(scenario.includes("reported-capacity") ? { initialContextUsage: { usedTokens: 999_999, maxTokens: 1_000_000 } } : {}), @@ -422,7 +445,7 @@ describe("orchestration v2 provider switching", () => { ? 1_000_000 : 32_000, } - : scenario === "large-current-input" + : scenario === "large-current-input" || priorImages ? { getModelContextWindow: () => 32_000 } : {}), ...(scenario.endsWith("-native") || scenario === "large-current-input" @@ -463,7 +486,11 @@ describe("orchestration v2 provider switching", () => { creationSource: "web", text, attachments: - scenario.startsWith("screenshot") && ordinal >= targetOrdinal ? screenshots : [], + scenario.startsWith("screenshot") && ordinal >= targetOrdinal + ? screenshots + : priorImages && ordinal === (scenario.startsWith("imported") ? 1 : 2) + ? [screenshot] + : [], modelSelection: selection, dispatchMode: { type: "start_immediately" }, }); @@ -523,6 +550,7 @@ describe("orchestration v2 provider switching", () => { // First establish the returning native thread: the current request must // not be charged as existing context on the subsequent handoff. if (returning) { + if (scenario.includes("unsent")) yield* Ref.set(failStartOnce, true); yield* dispatch(2, "Establish target", CLAUDE_MODEL_SELECTION); yield* wait(2); if (modelScenario) { @@ -550,9 +578,62 @@ describe("orchestration v2 provider switching", () => { ], }); } - yield* dispatch(3, "New source constraint", CODEX_MODEL_SELECTION); + if (scenario.includes("legacy-replacement")) { + const existing = yield* orchestrator.getThreadProjection(threadId); + yield* eventSink.write({ + events: existing.attempts.map( + ({ nativeThreadId: _nativeThreadId, ...legacy }, index) => ({ + id: EventId.make(`legacy-attempt:${index}`), + type: "run-attempt.updated" as const, + threadId, + occurredAt: existing.thread.createdAt, + payload: legacy, + }), + ), + }); + } + if (scenario.includes("telemetry")) { + const target = (yield* orchestrator.getThreadProjection( + threadId, + )).providerThreads.find( + (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, + )!; + yield* eventSink.write({ + events: [ + { + id: EventId.make("compacted-context-usage"), + type: "provider-thread.updated", + threadId, + occurredAt: yield* DateTime.now, + payload: { ...target, contextUsage: { usedTokens: 100, maxTokens: 32_000 } }, + }, + ], + }); + } + yield* dispatch( + 3, + priorImages && !replaceNative + ? "New source constraint " + "q".repeat(9_000) + : "New source constraint", + CODEX_MODEL_SELECTION, + ); yield* wait(3); } + if (replaceNative) { + const target = (yield* orchestrator.getThreadProjection( + threadId, + )).providerThreads.find( + (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, + )!; + yield* orchestrator.dispatch({ + type: "provider-session.detach", + commandId: CommandId.make("detach-for-native-replacement"), + threadId, + providerSessionId: target.providerSessionId!, + }); + yield* worker.drain(); + yield* Ref.set(failResumeOnce, true); + } if (scenario.includes("retry")) yield* Ref.set(failStartOnce, true); yield* dispatch(targetOrdinal, current, targetSelection); yield* wait(targetOrdinal); @@ -567,8 +648,86 @@ describe("orchestration v2 provider switching", () => { yield* dispatch(targetOrdinal + 1, current, targetSelection); yield* wait(targetOrdinal + 1); } + if (replaceNative) { + if (scenario.includes("legacy-replacement")) { + const recovered = yield* orchestrator.getThreadProjection(threadId); + const handoff = recovered.contextHandoffs.at(-1)!; + const { history: _history, ...legacyHandoff } = handoff; + yield* eventSink.write({ + events: [ + { + id: EventId.make("legacy-replacement-handoff"), + type: "context-handoff.updated", + threadId, + occurredAt: yield* DateTime.now, + payload: { + ...legacyHandoff, + delivery: { + nativeThreadId: handoff.delivery!.nativeThreadId, + status: handoff.delivery!.status, + itemIds: [], + }, + }, + }, + ], + }); + } + yield* dispatch( + targetOrdinal + 1, + "Continue after native replacement", + CLAUDE_MODEL_SELECTION, + ); + yield* wait(targetOrdinal + 1); + yield* dispatch( + targetOrdinal + 2, + "Another source constraint " + "q".repeat(9_000), + CODEX_MODEL_SELECTION, + ); + yield* wait(targetOrdinal + 2); + yield* dispatch(targetOrdinal + 3, current, CLAUDE_MODEL_SELECTION); + yield* wait(targetOrdinal + 3); + } const projection = yield* orchestrator.getThreadProjection(threadId); assert.equal(projection.runs.at(-1)?.status, "completed"); + if (priorImages) { + const handoff = projection.contextHandoffs.at(-1)!; + const context = scenario.endsWith("-native") + ? yield* encodeJson(yield* Ref.get(injectedHistory)) + : (yield* Ref.get(capturedTurns)).at(-1)!.text; + const shouldFit = + scenario.startsWith("imported") || + scenario.includes("telemetry") || + scenario.includes("unsent") || + replaceNative; + const sourceText = + `${replaceNative ? "Another" : "New"} source constraint ` + "q".repeat(9_000); + const sourceItem = projection.turnItems.find( + (item) => item.type === "user_message" && item.text === sourceText, + )!; + assert.isDefined(sourceItem); + if (shouldFit) { + assert.include(context, sourceText); + assert.include( + projection.contextHandoffs + .filter((record) => record.targetRunId === projection.runs.at(-1)!.id) + .flatMap((record) => record.delivery?.itemIds ?? []), + sourceItem.id, + ); + } else { + assert.notInclude(context, "q".repeat(9_000)); + assert.isAbove(handoff.delivery!.omittedItemIds!.length, 0); + } + const latestRun = projection.runs.at(-1)!; + const latestAttempt = projection.attempts.find( + (attempt) => attempt.id === latestRun.activeAttemptId, + )!; + const target = projection.providerThreads.find( + (thread) => thread.id === latestAttempt.providerThreadId, + )!; + assert.equal(latestAttempt.nativeThreadId, target.nativeThreadRef!.nativeId); + if (replaceNative) assert.equal(yield* Ref.get(generation), 2); + return; + } const handoff = projection.contextHandoffs.at(-1)!; if (scenario.startsWith("screenshot")) { assert.deepEqual((yield* Ref.get(capturedTurns)).at(-1)!.attachments, screenshots); @@ -2193,9 +2352,21 @@ describe("orchestration v2 provider switching", () => { Stream.runHead, ); yield* worker.drain(); - return yield* orchestrator.getThreadProjection(importedThreadId); + } else { + yield* waitForIdle(importedThreadId); + } + const beforeRebuild = yield* orchestrator.getThreadProjection(importedThreadId); + for (const attempt of beforeRebuild.attempts) { + const nativeId = beforeRebuild.providerThreads.find( + (thread) => thread.id === attempt.providerThreadId, + )?.nativeThreadRef?.nativeId; + assert.isDefined(nativeId); + assert.equal(attempt.nativeThreadId, nativeId); } - return yield* waitForIdle(importedThreadId); + yield* maintenance.rebuild; + const rebuilt = yield* orchestrator.getThreadProjection(importedThreadId); + assert.deepEqual(rebuilt.attempts, beforeRebuild.attempts); + return rebuilt; }).pipe(Effect.provide(testLayer)); const turns = yield* Ref.get(capturedTurns); diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 6d2f9d8145f2..582b341597d7 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -474,6 +474,8 @@ export type OrchestrationV2Run = typeof OrchestrationV2Run.Type; export const OrchestrationV2RunAttempt = Schema.Struct({ id: RunAttemptId, + // Provider-thread rows can be reused after recovery; retain the native input destination. + nativeThreadId: Schema.optional(Schema.String), runId: RunId, attemptOrdinal: PositiveInt, rootNodeId: NodeId, From 983e6945c5e11dc4a9f1052f50c5ea3e489609ca Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 18 Sep 2026 15:39:03 -0700 Subject: [PATCH 10/13] fix(server): retain Codex capacity across reasoning changes --- .../Adapters/CodexAdapterV2.test.ts | 28 ++++ .../Adapters/CodexAdapterV2.ts | 15 +- .../src/orchestration-v2/ProviderAdapter.ts | 4 + .../ProviderTurnStartService.ts | 25 ++- .../ProviderSwitch.integration.test.ts | 145 ++++++++++++++---- 5 files changed, 179 insertions(+), 38 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index eb8e633b4f5e..83960a73c69a 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -61,6 +61,7 @@ import { import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; import { buildCodexTurnStartParams, + canReuseCodexContextUsage, CODEX_DEFAULT_INSTANCE_ID, CODEX_DRIVER_KIND, codexBackgroundCommandDetail, @@ -86,6 +87,33 @@ const encodeReplayTranscriptJson = Schema.encodeEffect(replayTranscriptJson); const decodeReplayTranscriptJson = Schema.decodeUnknownEffect(replayTranscriptJson); const encodeStringJson = Schema.encodeEffect(Schema.fromJsonString(Schema.String)); +describe("Codex context usage compatibility", () => { + const previous: ModelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-6-astra", + }; + it("retains measured usage for reasoning-only changes in either direction", () => { + const low: ModelSelection = { ...previous, options: [{ id: "reasoningEffort", value: "low" }] }; + assert.isTrue(canReuseCodexContextUsage(previous, low)); + assert.isTrue(canReuseCodexContextUsage(low, previous)); + assert.isTrue( + canReuseCodexContextUsage(low, { + ...low, + options: [{ id: "reasoningEffort", value: "high" }], + }), + ); + }); + it("invalidates usage for model, instance, context-window and unknown option changes", () => { + for (const next of [ + { ...previous, model: "other-model" }, + { ...previous, instanceId: ProviderInstanceId.make("other-codex") }, + { ...previous, options: [{ id: "contextWindow", value: "32k" }] }, + { ...previous, options: [{ id: "customOption", value: "value" }] }, + ]) + assert.isFalse(canReuseCodexContextUsage(previous, next)); + }); +}); + describe("CodexAdapterV2 file change approvals", () => { it("uses nonblank reasons before sorted file operations and renamed paths", () => { const fileChanges = { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 04d0d214b841..eb979952e469 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -15,7 +15,7 @@ import type { ServerProviderShape } from "../../provider/Services/ServerProvider import { codexRateLimitsToUpdate } from "../../provider/Layers/codexUsageLimits.ts"; import { CodexSettings, defaultInstanceIdForDriver, ProviderDriverKind } from "@t3tools/contracts"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; -import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import type { ChatAttachment, @@ -153,6 +153,18 @@ export function codexFileChangeApprovalPrompt(input: { return input.grantRoot?.trim() || undefined; } +// Reasoning effort changes future generation, not the existing context's tokenizer +// or model window. All other option changes remain untrusted until new telemetry. +export function canReuseCodexContextUsage(previous: ModelSelection, next: ModelSelection): boolean { + return modelSelectionsEqual( + { + ...previous, + options: (previous.options ?? []).filter((option) => option.id !== "reasoningEffort"), + }, + { ...next, options: (next.options ?? []).filter((option) => option.id !== "reasoningEffort") }, + ); +} + export function codexProviderTurnTokenUsage( tokenUsage: CodexSchema.V2ThreadTokenUsageUpdatedNotification["tokenUsage"], updatedAt: string, @@ -4928,6 +4940,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi providerSessionId: input.providerSessionId, providerSession: session, events: Stream.fromEffectRepeat(Queue.take(events)), + canReuseContextUsage: canReuseCodexContextUsage, // Known gap: a subagent that Codex resumes later reads as completed // (not pending) between turns, so idle release can win the race // against a long-delayed resume. Codex emits no resume-expected diff --git a/apps/server/src/orchestration-v2/ProviderAdapter.ts b/apps/server/src/orchestration-v2/ProviderAdapter.ts index 6131232427ab..478150bd59f6 100644 --- a/apps/server/src/orchestration-v2/ProviderAdapter.ts +++ b/apps/server/src/orchestration-v2/ProviderAdapter.ts @@ -509,6 +509,10 @@ export interface ProviderAdapterV2SessionRuntime { ) => Effect.Effect; /** Capacity for the requested model/options, independent of native thread usage. */ readonly getModelContextWindow?: (modelSelection: ModelSelection) => number | undefined; + /** Whether an option-only change preserves measured native usage and capacity. + * Compaction thresholds are still discarded. Unknown transitions invalidate usage. + */ + readonly canReuseContextUsage?: (previous: ModelSelection, next: ModelSelection) => boolean; readonly ensureThread: ( input: ProviderAdapterV2EnsureThreadInput, ) => Effect.Effect; diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 0de736376452..aab486687891 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -565,11 +565,29 @@ export const layer: Layer.Layer< const sameSelection = previousSelection === undefined || modelSelectionsEqual(previousSelection, run.modelSelection); + const sameNativeThread = + loadedProviderThread.nativeThreadRef?.nativeId === providerThread.nativeThreadRef?.nativeId; + const previousUsage = loadedProviderThread.contextUsage ?? providerThread.contextUsage; + const compatibleUsage = + previousSelection !== undefined && + session.canReuseContextUsage?.(previousSelection, run.modelSelection) && + previousUsage != null + ? { + usedTokens: previousUsage.usedTokens, + ...(previousUsage.maxTokens === undefined + ? {} + : { maxTokens: previousUsage.maxTokens }), + } + : null; const runningProviderThread: OrchestrationV2ProviderThread = { ...loadedProviderThread, // Persist invalidation before delivery: a failed start must not let the next // attempt mistake old-model telemetry for usage of the new selection. - contextUsage: sameSelection ? (loadedProviderThread.contextUsage ?? null) : null, + contextUsage: sameSelection + ? (loadedProviderThread.contextUsage ?? null) + : sameNativeThread + ? compatibleUsage + : null, id: providerThread.id, driver: session.driver, providerInstanceId: run.providerInstanceId, @@ -701,9 +719,6 @@ export const layer: Layer.Layer< const tokenCap = yield* handoffTokenCapConfig.pipe( Effect.orElseSucceed(() => DEFAULT_HANDOFF_TOKEN_CAP), ); - const sameNativeThread = - runningProviderThread.nativeThreadRef?.nativeId === - providerThread.nativeThreadRef?.nativeId; const settledHandoffs = projection.contextHandoffs.filter( (handoff) => handoff.toProviderThreadId === providerThread.id && @@ -787,7 +802,7 @@ export const layer: Layer.Layer< : 0; const reportedUsage = sameSelection ? (runningProviderThread.contextUsage ?? providerThread.contextUsage) - : undefined; + : compatibleUsage; const modelContextWindow = session.getModelContextWindow?.(run.modelSelection) ?? reportedUsage?.maxTokens; // Replacing a native thread clears its usage, not the selected model's capacity. diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index f20fc4fff99b..e04567adf26e 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -35,7 +35,10 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; import { CommandPolicyCapabilityUnsupportedError } from "../CommandPolicy.ts"; import { ClaudeProviderCapabilitiesV2 } from "../Adapters/ClaudeAdapterV2.ts"; -import { CodexProviderCapabilitiesV2 } from "../Adapters/CodexAdapterV2.ts"; +import { + CodexProviderCapabilitiesV2, + canReuseCodexContextUsage, +} from "../Adapters/CodexAdapterV2.ts"; import { AcpProviderCapabilitiesV2 } from "../Adapters/AcpAdapterV2.ts"; import { CursorProviderCapabilitiesV2 } from "../Adapters/CursorAdapterV2.ts"; import { layer as eventSinkLayer } from "../EventSink.ts"; @@ -115,6 +118,7 @@ function makeTestAdapter(input: { readonly failResumeOnce?: Ref.Ref; readonly initialContextUsage?: OrchestrationV2ProviderThread["contextUsage"]; readonly getModelContextWindow?: (selection: ModelSelection) => number | undefined; + readonly canReuseContextUsage?: ProviderAdapterV2SessionRuntime["canReuseContextUsage"]; readonly failedRunOrdinals?: ReadonlySet; readonly interruptedRunOrdinals?: ReadonlySet; readonly holdRunOrdinal?: number; @@ -152,6 +156,9 @@ function makeTestAdapter(input: { ...(input.getModelContextWindow === undefined ? {} : { getModelContextWindow: input.getModelContextWindow }), + ...(input.canReuseContextUsage === undefined + ? {} + : { canReuseContextUsage: input.canReuseContextUsage }), ensureThread: (threadInput) => Effect.gen(function* () { const createdAt = yield* DateTime.now; @@ -379,6 +386,10 @@ describe("orchestration v2 provider switching", () => { "screenshot-option-change-native", "screenshot-model-change-retry-native", "screenshot-option-change-retry-native", + "prior-images-reasoning-change-replacement-native", + "prior-images-reasoning-change-replacement-small-native", + "prior-images-reasoning-change-native", + "prior-images-reasoning-change-retry-native", "prior-images-native", "prior-images-fallback", "imported-prior-images-native", @@ -395,7 +406,9 @@ describe("orchestration v2 provider switching", () => { const cwd = yield* checkpointWorkspace(`handoff-${scenario}`); const capturedTurns = yield* Ref.make>([]); const injectedHistory = yield* Ref.make>([]); - const modelScenario = scenario.includes("model") || scenario.includes("option-change"); + const reasoningScenario = scenario.includes("reasoning-change"); + const modelScenario = + scenario.includes("model") || scenario.includes("option-change") || reasoningScenario; const failStartOnce = yield* Ref.make(false); const failResumeOnce = yield* Ref.make(false); const generation = yield* Ref.make(0); @@ -408,9 +421,11 @@ describe("orchestration v2 provider switching", () => { ? CLAUDE_MODEL_SELECTION : { ...CLAUDE_MODEL_SELECTION, - ...(scenario.includes("option-change") - ? { options: [{ id: "contextWindow", value: "1m" }] } - : { model: `${CLAUDE_MODEL_SELECTION.model}-large` }), + ...(reasoningScenario + ? { options: [{ id: "reasoningEffort", value: "low" }] } + : scenario.includes("option-change") + ? { options: [{ id: "contextWindow", value: "1m" }] } + : { model: `${CLAUDE_MODEL_SELECTION.model}-large` }), }; const registry = makeProviderAdapterRegistryLayer([ makeTestAdapter({ @@ -435,19 +450,21 @@ describe("orchestration v2 provider switching", () => { ...(scenario.includes("reported-capacity") ? { initialContextUsage: { usedTokens: 999_999, maxTokens: 1_000_000 } } : {}), - ...(modelScenario - ? { - getModelContextWindow: (selection: ModelSelection) => - selection.model.endsWith("-large") || - selection.options?.some( - (option) => option.id === "contextWindow" && option.value === "1m", - ) - ? 1_000_000 - : 32_000, - } - : scenario === "large-current-input" || priorImages - ? { getModelContextWindow: () => 32_000 } - : {}), + ...(reasoningScenario + ? { canReuseContextUsage: canReuseCodexContextUsage } + : modelScenario + ? { + getModelContextWindow: (selection: ModelSelection) => + selection.model.endsWith("-large") || + selection.options?.some( + (option) => option.id === "contextWindow" && option.value === "1m", + ) + ? 1_000_000 + : 32_000, + } + : scenario === "large-current-input" || priorImages + ? { getModelContextWindow: () => 32_000 } + : {}), ...(scenario.endsWith("-native") || scenario === "large-current-input" ? { injectedHistory } : {}), @@ -486,11 +503,18 @@ describe("orchestration v2 provider switching", () => { creationSource: "web", text, attachments: - scenario.startsWith("screenshot") && ordinal >= targetOrdinal - ? screenshots - : priorImages && ordinal === (scenario.startsWith("imported") ? 1 : 2) - ? [screenshot] - : [], + reasoningScenario && ordinal === 2 + ? Array.from({ length: 8 }, (_, index) => ({ + ...screenshot, + id: `prior-${index}`, + })) + : reasoningScenario && ordinal >= targetOrdinal + ? [screenshot, { ...screenshot, id: "current-2" }] + : scenario.startsWith("screenshot") && ordinal >= targetOrdinal + ? screenshots + : priorImages && ordinal === (scenario.startsWith("imported") ? 1 : 2) + ? [screenshot] + : [], modelSelection: selection, dispatchMode: { type: "start_immediately" }, }); @@ -542,7 +566,7 @@ describe("orchestration v2 provider switching", () => { yield* Effect.addFinalizer(() => Effect.sync(() => spy?.mockRestore())); const current = scenario.startsWith("compact") ? "/compact" - : capacityScenario + : capacityScenario && !reasoningScenario ? "x".repeat(70_000) : scenario === "large-current-input" ? "x".repeat(9_000) @@ -551,7 +575,11 @@ describe("orchestration v2 provider switching", () => { // not be charged as existing context on the subsequent handoff. if (returning) { if (scenario.includes("unsent")) yield* Ref.set(failStartOnce, true); - yield* dispatch(2, "Establish target", CLAUDE_MODEL_SELECTION); + yield* dispatch( + 2, + reasoningScenario ? "x".repeat(27_460) : "Establish target", + CLAUDE_MODEL_SELECTION, + ); yield* wait(2); if (modelScenario) { const target = (yield* orchestrator.getThreadProjection( @@ -569,8 +597,18 @@ describe("orchestration v2 provider switching", () => { payload: { ...target, contextUsage: { - usedTokens: 30_000, - maxTokens: 32_000, + usedTokens: reasoningScenario + ? replaceNative + ? 63_000 + : 37_321 + : 30_000, + maxTokens: reasoningScenario + ? replaceNative + ? scenario.includes("small") + ? 32_000 + : 64_000 + : 258_400 + : 32_000, autoCompactThreshold: 31_000, }, }, @@ -640,14 +678,46 @@ describe("orchestration v2 provider switching", () => { if (scenario.includes("retry")) { const failed = yield* orchestrator.getThreadProjection(threadId); assert.equal(failed.runs.at(-1)?.status, "failed"); - assert.isNull( - failed.providerThreads.find( + const failedUsage = failed.providerThreads.find( + (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, + )!.contextUsage; + if (reasoningScenario) { + assert.deepEqual(failedUsage, { usedTokens: 37_321, maxTokens: 258_400 }); + } else { + assert.isNull(failedUsage); + } + if (reasoningScenario) { + const target = failed.providerThreads.find( (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, - )!.contextUsage, - ); + )!; + yield* orchestrator.dispatch({ + type: "provider-session.detach", + commandId: CommandId.make("detach-before-reasoning-retry"), + threadId, + providerSessionId: target.providerSessionId!, + }); + yield* worker.drain(); + } yield* dispatch(targetOrdinal + 1, current, targetSelection); yield* wait(targetOrdinal + 1); } + if (reasoningScenario && replaceNative) { + const replaced = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + replaced.runs.at(-1)?.status, + scenario.includes("small") ? "failed" : "completed", + ); + const target = replaced.providerThreads.find( + (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, + )!; + assert.isNull(target.contextUsage); + assert.equal(yield* Ref.get(generation), 2); + assert.equal( + (yield* Ref.get(capturedTurns)).at(-1)!.driver, + scenario.includes("small") ? CODEX_DRIVER : CLAUDE_DRIVER, + ); + return; + } if (replaceNative) { if (scenario.includes("legacy-replacement")) { const recovered = yield* orchestrator.getThreadProjection(threadId); @@ -695,6 +765,7 @@ describe("orchestration v2 provider switching", () => { ? yield* encodeJson(yield* Ref.get(injectedHistory)) : (yield* Ref.get(capturedTurns)).at(-1)!.text; const shouldFit = + reasoningScenario || scenario.startsWith("imported") || scenario.includes("telemetry") || scenario.includes("unsent") || @@ -709,7 +780,13 @@ describe("orchestration v2 provider switching", () => { assert.include(context, sourceText); assert.include( projection.contextHandoffs - .filter((record) => record.targetRunId === projection.runs.at(-1)!.id) + .filter( + (record) => + record.targetRunId === + (reasoningScenario && scenario.includes("retry") + ? projection.runs.find((run) => run.ordinal === targetOrdinal)!.id + : projection.runs.at(-1)!.id), + ) .flatMap((record) => record.delivery?.itemIds ?? []), sourceItem.id, ); @@ -725,6 +802,10 @@ describe("orchestration v2 provider switching", () => { (thread) => thread.id === latestAttempt.providerThreadId, )!; assert.equal(latestAttempt.nativeThreadId, target.nativeThreadRef!.nativeId); + if (reasoningScenario) { + assert.deepEqual(target.contextUsage, { usedTokens: 37_321, maxTokens: 258_400 }); + assert.lengthOf((yield* Ref.get(capturedTurns)).at(-1)!.attachments, 2); + } if (replaceNative) assert.equal(yield* Ref.get(generation), 2); return; } From 3231be2eb91a098242e0c999391d95cb5b7d2463 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 18 Sep 2026 15:50:36 -0700 Subject: [PATCH 11/13] fix(server): budget handoffs from native turn telemetry --- .../Adapters/CodexAdapterV2.test.ts | 14 ++++ .../orchestration-v2/ContextHandoffBudget.ts | 50 +++++++++++ .../ProviderTurnStartService.ts | 35 ++++---- .../ProviderSwitch.integration.test.ts | 83 ++++++++++++++----- 4 files changed, 145 insertions(+), 37 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 83960a73c69a..d8fa9118cceb 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -2021,6 +2021,20 @@ describe("CodexAdapterV2 post-settle continuation", () => { questionNode?.type === "node.updated" && questionNode.node.countsForRun, false, ); + const contextReport = harness.events.find( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.tokenUsage !== undefined, + ); + assert.equal(contextReport?.type, "provider_turn.updated"); + if (contextReport?.type === "provider_turn.updated") { + assert.equal( + contextReport.providerTurn.runAttemptId, + RunAttemptId.make("async-question-attempt"), + ); + assert.equal(contextReport.providerTurn.providerThreadId, harness.providerThread.id); + assert.equal(contextReport.providerTurn.tokenUsage?.usedTokens, 15); + assert.equal(contextReport.providerTurn.tokenUsage?.maxTokens, 200_000); + } const completed = harness.events.find( (event) => event.type === "provider_turn.updated" && event.providerTurn.status === "completed", diff --git a/apps/server/src/orchestration-v2/ContextHandoffBudget.ts b/apps/server/src/orchestration-v2/ContextHandoffBudget.ts index 31f945509580..2289a756c69e 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffBudget.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffBudget.ts @@ -1,5 +1,8 @@ import type { ChatAttachment, + ModelSelection, + OrchestrationV2ThreadProjection, + ThreadTokenUsageSnapshot, OrchestrationV2ContextHandoff, OrchestrationV2HistoricalMessage, OrchestrationV2ProviderThread, @@ -15,6 +18,53 @@ export const handoffTokenCapConfig = Config.Int("T3CODE_CONTEXT_HANDOFF_TOKEN_CA Config.map((value) => Math.max(1_024, Math.min(HANDOFF_BYTE_CAP, value))), ); +// Live reports belong to provider turns. Use only accepted root attempts whose +// durable native identity matches this thread; row reuse must not revive old usage. +export function latestNativeContextUsage( + projection: Pick, + providerThread: OrchestrationV2ProviderThread, +) { + const nativeId = providerThread.nativeThreadRef?.nativeId; + if (nativeId === undefined) return undefined; + const attempts = new Map(projection.attempts.map((attempt) => [attempt.id, attempt])); + const runs = new Map(projection.runs.map((run) => [run.id, run])); + let latest: + | { + usage: ThreadTokenUsageSnapshot; + modelSelection: ModelSelection; + reportedAt: string; + } + | undefined; + for (const turn of projection.providerTurns) { + if ( + turn.providerThreadId !== providerThread.id || + turn.runAttemptId === null || + !turn.tokenUsage + ) + continue; + const attempt = attempts.get(turn.runAttemptId); + if ( + attempt?.nativeThreadId !== nativeId || + attempt.providerThreadId !== providerThread.id || + attempt.rootNodeId !== turn.nodeId + ) + continue; + const run = runs.get(attempt.runId); + if (!run || (latest && latest.reportedAt >= turn.tokenUsage.updatedAt)) continue; + latest = { + usage: { + usedTokens: turn.tokenUsage.usedTokens, + ...(turn.tokenUsage.maxTokens != null && turn.tokenUsage.maxTokens > 0 + ? { maxTokens: turn.tokenUsage.maxTokens } + : {}), + }, + modelSelection: run.modelSelection, + reportedAt: turn.tokenUsage.updatedAt, + }; + } + return latest; +} + export function attachmentTokenAllowance(attachments: ReadonlyArray): number { // Encoded image bytes are not model tokens. Without dimensions/detail metadata, // reserve 8k tokens per image, above typical resized Codex/Claude image costs. diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index aab486687891..48c7fcd3b6c4 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -31,6 +31,7 @@ import { handoffBudget, attachmentTokenAllowance, historicalMessage, + latestNativeContextUsage, } from "./ContextHandoffBudget.ts"; import { deliverContextHandoffs } from "./ContextHandoffDelivery.ts"; import { @@ -556,18 +557,24 @@ export const layer: Layer.Layer< const now = yield* DateTime.now; // Only started runs reached the provider-thread update below. Queued runs and // failures during session setup cannot establish a new telemetry selection. - const previousSelection = projection.runs.findLast( - (source) => - source.ordinal < run.ordinal && - source.startedAt !== null && - source.providerThreadId === providerThread.id, - )?.modelSelection; + const measuredContext = latestNativeContextUsage(projection, providerThread); + const previousSelection = + measuredContext?.modelSelection ?? + projection.runs.findLast( + (source) => + source.ordinal < run.ordinal && + source.startedAt !== null && + source.providerThreadId === providerThread.id, + )?.modelSelection; const sameSelection = previousSelection === undefined || modelSelectionsEqual(previousSelection, run.modelSelection); const sameNativeThread = loadedProviderThread.nativeThreadRef?.nativeId === providerThread.nativeThreadRef?.nativeId; - const previousUsage = loadedProviderThread.contextUsage ?? providerThread.contextUsage; + const threadUsage = loadedProviderThread.contextUsage ?? providerThread.contextUsage; + const previousUsage = measuredContext + ? { ...threadUsage, ...measuredContext.usage } + : threadUsage; const compatibleUsage = previousSelection !== undefined && session.canReuseContextUsage?.(previousSelection, run.modelSelection) && @@ -583,11 +590,11 @@ export const layer: Layer.Layer< ...loadedProviderThread, // Persist invalidation before delivery: a failed start must not let the next // attempt mistake old-model telemetry for usage of the new selection. - contextUsage: sameSelection - ? (loadedProviderThread.contextUsage ?? null) - : sameNativeThread - ? compatibleUsage - : null, + contextUsage: sameNativeThread + ? sameSelection + ? (previousUsage ?? null) + : compatibleUsage + : null, id: providerThread.id, driver: session.driver, providerInstanceId: run.providerInstanceId, @@ -800,9 +807,7 @@ export const layer: Layer.Layer< ); }, 0) : 0; - const reportedUsage = sameSelection - ? (runningProviderThread.contextUsage ?? providerThread.contextUsage) - : compatibleUsage; + const reportedUsage = sameSelection ? previousUsage : compatibleUsage; const modelContextWindow = session.getModelContextWindow?.(run.modelSelection) ?? reportedUsage?.maxTokens; // Replacing a native thread clears its usage, not the selected model's capacity. diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index e04567adf26e..7b9d8c8a5a63 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -11,6 +11,7 @@ import { type OrchestrationV2ProviderCapabilities, type OrchestrationV2ProviderSession, type OrchestrationV2ProviderThread, + type OrchestrationV2ProviderTurnTokenUsage, ProjectId, ProviderInstanceId, ProviderSessionId, @@ -119,6 +120,9 @@ function makeTestAdapter(input: { readonly initialContextUsage?: OrchestrationV2ProviderThread["contextUsage"]; readonly getModelContextWindow?: (selection: ModelSelection) => number | undefined; readonly canReuseContextUsage?: ProviderAdapterV2SessionRuntime["canReuseContextUsage"]; + readonly tokenUsageByRunOrdinal?: Readonly< + Record> + >; readonly failedRunOrdinals?: ReadonlySet; readonly interruptedRunOrdinals?: ReadonlySet; readonly holdRunOrdinal?: number; @@ -331,6 +335,19 @@ function makeTestAdapter(input: { threadDisposition: "reusable", }, ]; + const reportedUsage = input.tokenUsageByRunOrdinal?.[turnInput.runOrdinal]; + const turnEvent = providerEvents[0]; + if (reportedUsage && turnEvent?.type === "provider_turn.updated") { + yield* PubSub.publish(events, { + ...turnEvent, + providerTurn: { + ...turnEvent.providerTurn, + status: "running", + completedAt: null, + tokenUsage: { ...reportedUsage, updatedAt: DateTime.formatIso(eventTime) }, + }, + }); + } for (const event of providerEvents) { yield* PubSub.publish(events, event); } @@ -389,6 +406,10 @@ describe("orchestration v2 provider switching", () => { "prior-images-reasoning-change-replacement-native", "prior-images-reasoning-change-replacement-small-native", "prior-images-reasoning-change-native", + "prior-images-turn-usage-native", + "prior-images-turn-usage-replacement-native", + "prior-images-turn-usage-model-change-native", + "prior-images-turn-usage-option-change-retry-native", "prior-images-reasoning-change-retry-native", "prior-images-native", "prior-images-fallback", @@ -407,6 +428,9 @@ describe("orchestration v2 provider switching", () => { const capturedTurns = yield* Ref.make>([]); const injectedHistory = yield* Ref.make>([]); const reasoningScenario = scenario.includes("reasoning-change"); + const turnUsageScenario = reasoningScenario || scenario.includes("turn-usage"); + const incompatibleTurnUsage = + scenario.includes("turn-usage") && scenario.includes("-change-"); const modelScenario = scenario.includes("model") || scenario.includes("option-change") || reasoningScenario; const failStartOnce = yield* Ref.make(false); @@ -450,8 +474,20 @@ describe("orchestration v2 provider switching", () => { ...(scenario.includes("reported-capacity") ? { initialContextUsage: { usedTokens: 999_999, maxTokens: 1_000_000 } } : {}), - ...(reasoningScenario - ? { canReuseContextUsage: canReuseCodexContextUsage } + ...(turnUsageScenario + ? { + canReuseContextUsage: canReuseCodexContextUsage, + tokenUsageByRunOrdinal: { + 2: { + usedTokens: replaceNative ? 30_000 : 37_321, + maxTokens: replaceNative + ? scenario.includes("small") + ? 32_000 + : 64_000 + : 258_400, + }, + }, + } : modelScenario ? { getModelContextWindow: (selection: ModelSelection) => @@ -503,12 +539,12 @@ describe("orchestration v2 provider switching", () => { creationSource: "web", text, attachments: - reasoningScenario && ordinal === 2 + turnUsageScenario && ordinal === 2 ? Array.from({ length: 8 }, (_, index) => ({ ...screenshot, id: `prior-${index}`, })) - : reasoningScenario && ordinal >= targetOrdinal + : turnUsageScenario && ordinal >= targetOrdinal ? [screenshot, { ...screenshot, id: "current-2" }] : scenario.startsWith("screenshot") && ordinal >= targetOrdinal ? screenshots @@ -566,7 +602,7 @@ describe("orchestration v2 provider switching", () => { yield* Effect.addFinalizer(() => Effect.sync(() => spy?.mockRestore())); const current = scenario.startsWith("compact") ? "/compact" - : capacityScenario && !reasoningScenario + : capacityScenario && !turnUsageScenario ? "x".repeat(70_000) : scenario === "large-current-input" ? "x".repeat(9_000) @@ -577,11 +613,11 @@ describe("orchestration v2 provider switching", () => { if (scenario.includes("unsent")) yield* Ref.set(failStartOnce, true); yield* dispatch( 2, - reasoningScenario ? "x".repeat(27_460) : "Establish target", + turnUsageScenario ? "x".repeat(27_460) : "Establish target", CLAUDE_MODEL_SELECTION, ); yield* wait(2); - if (modelScenario) { + if (modelScenario && !turnUsageScenario) { const target = (yield* orchestrator.getThreadProjection( threadId, )).providerThreads.find( @@ -597,18 +633,8 @@ describe("orchestration v2 provider switching", () => { payload: { ...target, contextUsage: { - usedTokens: reasoningScenario - ? replaceNative - ? 63_000 - : 37_321 - : 30_000, - maxTokens: reasoningScenario - ? replaceNative - ? scenario.includes("small") - ? 32_000 - : 64_000 - : 258_400 - : 32_000, + usedTokens: 30_000, + maxTokens: 32_000, autoCompactThreshold: 31_000, }, }, @@ -701,6 +727,17 @@ describe("orchestration v2 provider switching", () => { yield* dispatch(targetOrdinal + 1, current, targetSelection); yield* wait(targetOrdinal + 1); } + if (incompatibleTurnUsage) { + const invalidated = yield* orchestrator.getThreadProjection(threadId); + assert.equal(invalidated.runs.at(-1)?.status, "failed"); + assert.isNull( + invalidated.providerThreads.find( + (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, + )!.contextUsage, + ); + assert.equal((yield* Ref.get(capturedTurns)).at(-1)!.driver, CODEX_DRIVER); + return; + } if (reasoningScenario && replaceNative) { const replaced = yield* orchestrator.getThreadProjection(threadId); assert.equal( @@ -765,7 +802,7 @@ describe("orchestration v2 provider switching", () => { ? yield* encodeJson(yield* Ref.get(injectedHistory)) : (yield* Ref.get(capturedTurns)).at(-1)!.text; const shouldFit = - reasoningScenario || + turnUsageScenario || scenario.startsWith("imported") || scenario.includes("telemetry") || scenario.includes("unsent") || @@ -802,8 +839,10 @@ describe("orchestration v2 provider switching", () => { (thread) => thread.id === latestAttempt.providerThreadId, )!; assert.equal(latestAttempt.nativeThreadId, target.nativeThreadRef!.nativeId); - if (reasoningScenario) { - assert.deepEqual(target.contextUsage, { usedTokens: 37_321, maxTokens: 258_400 }); + if (turnUsageScenario) { + if (replaceNative) assert.isNull(target.contextUsage); + else + assert.deepEqual(target.contextUsage, { usedTokens: 37_321, maxTokens: 258_400 }); assert.lengthOf((yield* Ref.get(capturedTurns)).at(-1)!.attachments, 2); } if (replaceNative) assert.equal(yield* Ref.get(generation), 2); From c84672d1fbb8a4d70b39fc85940e75a803ef64a4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 18 Sep 2026 15:55:38 -0700 Subject: [PATCH 12/13] fix(server): bound handoff persistence warnings --- apps/server/src/orchestration-v2/ProviderTurnStartService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 48c7fcd3b6c4..1e712dc48a68 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -923,10 +923,10 @@ export const layer: Layer.Layer< // The provider already accepted the turn. A stale pending marker // can force a fresh thread later, but must not stop live ingestion. yield* delivery.delivered.pipe( - Effect.catchCause((cause) => + Effect.catchCause(() => Effect.logWarning("Failed to record accepted context handoff delivery", { runId: run.id, - cause: Cause.pretty(cause), + deliveryStatus: "pending", }), ), ); From bb6da5c1cfe140edd000c8c1b9628d7e911a85b4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 18 Sep 2026 16:01:23 -0700 Subject: [PATCH 13/13] fix(server): preserve structured provider start errors --- .../ProviderTurnStartService.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 1e712dc48a68..b0b632cdc2e8 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -931,15 +931,16 @@ export const layer: Layer.Layer< ), ); }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapterTurnStartError({ - driver: session.driver, - threadId: projection.thread.id, - providerThreadId: providerThread.id, - runId: run.id, - cause, - }), + Effect.mapError((cause) => + cause._tag === "ProviderAdapterTurnStartError" + ? cause + : new ProviderAdapterTurnStartError({ + driver: session.driver, + threadId: projection.thread.id, + providerThreadId: providerThread.id, + runId: run.id, + cause, + }), ), ); const deliverySession =