From bf96f7b956b288d5abb26ffcd6a63e7d8bf678ae Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 07:53:04 +0800 Subject: [PATCH 01/16] fix(runtime): fail closed on cross-model reasoning replay Generated-by: Codex --- packages/core/src/backend-types.ts | 9 + .../execution-model-composition.test.ts | 86 +++++- .../src/server/execution-model-composition.ts | 1 + .../src/__tests__/ai-sdk-backend.test.ts | 265 +++++++++++++++++- .../computer-use-provider-protocol.test.ts | 75 ++--- .../openai-codex-history-compactor.test.ts | 11 - .../src/__tests__/session-manager.test.ts | 123 +++++++- packages/runtime/src/agent-run.ts | 9 +- packages/runtime/src/ai-sdk-backend.ts | 18 ++ .../runtime/src/ai-sdk-compaction-contract.ts | 8 +- packages/runtime/src/ai-sdk-compaction.ts | 40 ++- packages/runtime/src/model-history.ts | 26 ++ .../src/openai-codex-history-compactor.ts | 27 +- packages/runtime/src/runtime-kernel.ts | 4 + 14 files changed, 637 insertions(+), 65 deletions(-) diff --git a/packages/core/src/backend-types.ts b/packages/core/src/backend-types.ts index 46262ca9f2..a0d1e9b67e 100644 --- a/packages/core/src/backend-types.ts +++ b/packages/core/src/backend-types.ts @@ -40,6 +40,7 @@ import type { InteractionClosureReason } from './interaction.js'; import type { RuntimeEvent } from './runtime-event.js'; import type { SandboxBoundaryResponse, SandboxBoundarySettlement } from './sandbox-boundary.js'; import type { StoredMessage, PersistedBackendKind } from './session.js'; +import type { AgentRunHeader } from './agent-run.js'; import type { UserQuestionResponse } from './user-question.js'; import type { ContextBudgetDiagnostic } from './usage-stats/types.js'; import type { EffectiveOrchestration } from './orchestration.js'; @@ -88,6 +89,12 @@ export interface BackendSendInput { * compatibility projection. */ runtimeContext?: RuntimeEvent[]; + /** + * Existing durable run headers for `runtimeContext`, used only to verify + * provider-owned replay against the current model route. RuntimeEvents stay + * the transcript authority; route provenance remains owned by AgentRun. + */ + runtimeContextRunHeaders?: AgentRunHeader[]; /** Continue from an already committed RuntimeEvent boundary without adding another user turn. */ continuation?: RuntimeContinuationMetadata; /** @@ -167,6 +174,8 @@ export interface BackendCompactHistoryInput { */ runId: string; runtimeContext: readonly RuntimeEvent[]; + /** Source-run route authority for provider-owned history projected into the compaction call. */ + runtimeContextRunHeaders?: readonly AgentRunHeader[]; } export interface BackendCompactHistoryResult { diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 83b6796d57..08b5b74e34 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -37,6 +37,8 @@ import { import { PROVIDER_DEFAULTS } from '@maka/core/llm-connections'; import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; import { decodeRunCompositionSnapshot } from '@maka/core/run-composition'; +import type { AgentRunHeader } from '@maka/core/agent-run'; +import type { BackendCompactHistoryInput } from '@maka/core/backend-types'; import { decodeCanonicalToolResultContent } from '@maka/core/tool-result-record-schema'; import { type ModelCallAttempt, type ModelCallKind } from '@maka/core/model-call-attempt'; import { type RuntimeEvent } from '@maka/core/runtime-event'; @@ -920,6 +922,48 @@ test('Codex OAuth history compaction falls back to a text checkpoint after nativ 'agent', 'b'.repeat(8_000), ), + { + id: 'compact-old-reasoning', + invocationId: 'compact-invocation', + runId: 'compact-source-run', + sessionId: 'backend-creation-session', + turnId: 'turn-old-model', + ts: 2, + partial: false, + role: 'model', + author: 'agent', + content: { + kind: 'thinking', + text: 'CROSS_MODEL_PROVIDER_REASONING', + providerOptions: { + openai: { + itemId: 'cross-model-reasoning-item', + reasoningEncryptedContent: 'CROSS_MODEL_ENCRYPTED_REASONING', + }, + }, + }, + }, + { + id: 'compact-current-route-reasoning', + invocationId: 'compact-invocation', + runId: 'compact-same-route-run', + sessionId: 'backend-creation-session', + turnId: 'turn-current-route-model', + ts: 3, + partial: false, + role: 'model', + author: 'agent', + content: { + kind: 'thinking', + text: 'SAME_ROUTE_PROVIDER_REASONING', + providerOptions: { + openai: { + itemId: 'same-route-reasoning-item', + reasoningEncryptedContent: 'SAME_ROUTE_ENCRYPTED_REASONING', + }, + }, + }, + }, compactRuntimeTextEvent( 'compact-recent-user', 'turn-recent-user', @@ -928,11 +972,44 @@ test('Codex OAuth history compaction falls back to a text checkpoint after nativ 'recent context', ), ]; - const result = await backend.compactHistory({ + const compactInput = { turnId: 'turn-compact', runId: 'run-compact', runtimeContext, - }); + runtimeContextRunHeaders: [ + { + runId: 'compact-source-run', + sessionId: 'backend-creation-session', + turnId: 'turn-old-model', + status: 'completed', + backendKind: 'ai-sdk', + llmConnectionId: '11111111-1111-4111-8111-111111111111', + llmConnectionSlug: 'backend-creation-connection', + modelId: 'gpt-5.2', + cwd: '/workspace', + permissionMode: 'bypass', + createdAt: 1, + updatedAt: 2, + completedAt: 2, + } satisfies AgentRunHeader, + { + runId: 'compact-same-route-run', + sessionId: 'backend-creation-session', + turnId: 'turn-current-route-model', + status: 'completed', + backendKind: 'ai-sdk', + llmConnectionId: '11111111-1111-4111-8111-111111111111', + llmConnectionSlug: 'backend-creation-connection', + modelId, + cwd: '/workspace', + permissionMode: 'bypass', + createdAt: 2, + updatedAt: 3, + completedAt: 3, + } satisfies AgentRunHeader, + ], + } satisfies BackendCompactHistoryInput; + const result = await backend.compactHistory(compactInput); assert.equal(requests.length, 2, JSON.stringify(result)); assert.match(requests[0]!.url, /\/codex\/responses$/); @@ -940,6 +1017,11 @@ test('Codex OAuth history compaction falls back to a text checkpoint after nativ const nativeRequestText = JSON.stringify(requests[0]!.body); const fallbackRequestText = JSON.stringify(requests[1]!.body); assert.match(nativeRequestText, /"type":"compaction_trigger"/); + assert.doesNotMatch(nativeRequestText, /CROSS_MODEL_PROVIDER_REASONING/); + assert.doesNotMatch(nativeRequestText, /CROSS_MODEL_ENCRYPTED_REASONING/); + assert.match(nativeRequestText, /SAME_ROUTE_PROVIDER_REASONING/); + assert.match(nativeRequestText, /SAME_ROUTE_ENCRYPTED_REASONING/); + assert.match(nativeRequestText, /recent context/); assert.doesNotMatch(nativeRequestText, /context summarization assistant/i); assert.doesNotMatch(fallbackRequestText, /"type":"compaction_trigger"/); assert.match(fallbackRequestText, /context summarization assistant/i); diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index 7d48e228a0..a856e425ff 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -198,6 +198,7 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom ? withOpenAiCodexHistoryCompactionFallback( buildOpenAiCodexHistoryCompactor({ resolveModel: resolveHistoryCompactModel, + connectionId: input.context.header.llmConnectionId, connectionSlug: target.connection.slug, modelId: target.model, providerOptions, diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index f91e6af20a..bc09bddbcf 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -5237,7 +5237,7 @@ describe('AiSdkBackend model history', () => { }); }); - test('does not redispatch an unchanged malformed compaction input', async () => { + test('does not redispatch unchanged compaction content for unrelated run provenance', async () => { let calls = 0; const backend = createTestAiSdkBackend({ sessionId: 'session-1', @@ -5280,16 +5280,31 @@ describe('AiSdkBackend model history', () => { text: 'recent', }), ]; + const sourceRunHeader = priorModelRunHeader({ + connectionId: 'test-connection-id', + modelId: 'mock-model-id', + }); + const priorCompactionRunHeader: AgentRunHeader = { + ...priorModelRunHeader({ + connectionId: 'test-connection-id', + modelId: 'mock-model-id', + runId: 'run-1', + }), + turnId: 'turn-compact-1', + rootExecutionKind: 'context_compact', + }; const first = await backend.compactHistory({ turnId: 'turn-compact-1', runId: 'run-1', runtimeContext: history, + runtimeContextRunHeaders: [sourceRunHeader], }); const repeated = await backend.compactHistory({ turnId: 'turn-compact-2', runId: 'run-2', runtimeContext: history, + runtimeContextRunHeaders: [sourceRunHeader, priorCompactionRunHeader], }); assert.equal(calls, 1); @@ -5312,6 +5327,7 @@ describe('AiSdkBackend model history', () => { text: 'new source history', }), ], + runtimeContextRunHeaders: [sourceRunHeader, priorCompactionRunHeader], }); assert.equal(calls, 2, 'changed source fingerprint is eligible again'); }); @@ -6608,6 +6624,203 @@ describe('AiSdkBackend model history', () => { assert.equal(promptJson.includes('private chain of thought'), false); }); + test('drops cross-model Anthropic reasoning while preserving text and tool history', async () => { + const model = completionModel(); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: { ...header(), llmConnectionId: 'connection-a', model: 'claude-b' }, + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'claude-b', + modelFactory: () => model, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'continue', + context: [], + runtimeContextRunHeaders: [ + priorModelRunHeader({ connectionId: 'connection-a', modelId: 'claude-a' }), + ], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-u', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'inspect the file', + }), + runtimeEvent({ + id: 'rt-thinking', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { kind: 'thinking', text: 'provider reasoning', signature: 'signature-a' }, + refs: { stepId: 'step-1' }, + }), + runtimeEvent({ + id: 'rt-call', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'tool-1', + name: 'Read', + args: { path: 'package.json' }, + }, + refs: { stepId: 'step-1' }, + }), + runtimeEvent({ + id: 'rt-result', + turnId: 'turn-prev', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'tool-1', + name: 'Read', + result: 'file contents', + }, + }), + runtimeEvent({ + id: 'rt-a', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'inspection complete' }, + refs: { stepId: 'step-1' }, + }), + ], + }), + ); + + const promptJson = JSON.stringify(compactPrompt(model)); + assert.equal(promptJson.includes('provider reasoning'), false); + assert.equal(promptJson.includes('signature-a'), false); + assert.match(promptJson, /inspection complete/); + assert.match(promptJson, /"toolCallId":"tool-1"/); + assert.match(promptJson, /file contents/); + }); + + test('fails closed for provider reasoning with no source run provenance', async () => { + const model = completionModel(); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: { ...header(), llmConnectionId: 'connection-a', model: 'claude-a' }, + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'claude-a', + modelFactory: () => model, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'continue', + context: [], + runtimeContext: [ + runtimeEvent({ + id: 'rt-thinking', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'thinking', + text: 'legacy signed reasoning', + signature: 'legacy-signature', + }, + }), + runtimeTextEvent({ + id: 'rt-a', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + text: 'legacy visible answer', + }), + ], + }), + ); + + const promptJson = JSON.stringify(compactPrompt(model)); + assert.equal(promptJson.includes('legacy signed reasoning'), false); + assert.equal(promptJson.includes('legacy-signature'), false); + assert.match(promptJson, /legacy visible answer/); + }); + + test('keeps same-route OpenAI Responses reasoning replay', async () => { + const model = completionModel(); + const openAiConnection: LlmConnection = { + ...connection(), + slug: 'openai-main', + providerType: 'openai', + defaultModel: 'gpt-5.4', + }; + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: { + ...header(), + llmConnectionId: 'connection-openai', + llmConnectionSlug: 'openai-main', + model: 'gpt-5.4', + }, + appendMessage: async () => {}, + connection: openAiConnection, + apiKey: 'sk-test', + modelId: 'gpt-5.4', + modelFactory: () => model, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'continue', + context: [], + runtimeContextRunHeaders: [ + priorModelRunHeader({ + connectionId: 'connection-openai', + connectionSlug: 'openai-main', + modelId: 'gpt-5.4', + }), + ], + runtimeContext: [ + runtimeEvent({ + id: 'rt-thinking', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'thinking', + text: 'responses reasoning', + providerOptions: { + openai: { + itemId: 'reasoning-item-1', + reasoningEncryptedContent: 'encrypted-reasoning', + }, + }, + }, + }), + ], + }), + ); + + const promptJson = JSON.stringify(compactPrompt(model)); + assert.match(promptJson, /reasoning-item-1/); + assert.match(promptJson, /encrypted-reasoning/); + }); + test('skips unsupported unsigned thinking without dropping native tool replay', async () => { const model = completionModel(); const backend = createTestAiSdkBackend({ @@ -11764,6 +11977,7 @@ describe('AiSdkBackend thinking persistence', () => { turnId: 'turn-current', text: 'follow up', context: [], + ...sameRouteReplayProvenance('claude-opus-4-8'), runtimeContext, }), ); @@ -11864,6 +12078,7 @@ describe('AiSdkBackend thinking persistence', () => { turnId: 'turn-current', text: 'follow up', context: [], + ...sameRouteReplayProvenance('mock-model-id'), runtimeContext, }), ); @@ -11987,6 +12202,7 @@ describe('AiSdkBackend thinking persistence', () => { turnId: 'turn-current', text: 'follow up', context: [], + ...sameRouteReplayProvenance('gpt-5.5'), runtimeContext, }), ); @@ -12138,6 +12354,7 @@ describe('AiSdkBackend thinking persistence', () => { turnId: 'turn-current', text: 'follow up', context: [], + ...sameRouteReplayProvenance('ark-code-latest'), runtimeContext, }), ); @@ -12248,6 +12465,7 @@ describe('AiSdkBackend thinking persistence', () => { turnId: 'turn-current', text: 'follow up', context: [], + ...sameRouteReplayProvenance('deepseek-v4-flash'), runtimeContext, }), ); @@ -12463,6 +12681,7 @@ describe('AiSdkBackend thinking persistence', () => { turnId: 'turn-current', text: 'follow up', context: [], + ...sameRouteReplayProvenance('qwen3.8-max'), runtimeContext, }), ); @@ -12601,6 +12820,7 @@ describe('AiSdkBackend thinking persistence', () => { turnId: 'turn-2', text: 'recover', context: [], + ...sameRouteReplayProvenance('qwen3.8-max'), runtimeContext, }), ); @@ -12807,6 +13027,7 @@ describe('AiSdkBackend thinking persistence', () => { turnId: 'turn-2', text: 'recover', context: [], + ...sameRouteReplayProvenance('qwen3.8-max', 'run-1'), runtimeContext, }), ); @@ -12951,6 +13172,7 @@ describe('AiSdkBackend thinking persistence', () => { turnId: 'turn-2', text: 'follow up', context: [], + ...sameRouteReplayProvenance('qwen3.8-max', 'run-1'), runtimeContext, }), ); @@ -13045,6 +13267,7 @@ describe('AiSdkBackend thinking persistence', () => { turnId: 'turn-current', text: 'follow up', context: [], + ...sameRouteReplayProvenance('qwen3.8-max'), runtimeContext, }), ); @@ -13106,6 +13329,7 @@ describe('AiSdkBackend thinking persistence', () => { turnId: 'turn-current', text: 'follow up', context: [], + ...sameRouteReplayProvenance('qwen3.8-max'), runtimeContext, }), ), @@ -13349,6 +13573,7 @@ describe('AiSdkBackend thinking persistence', () => { turnId: 'turn-current', text: 'follow up', context: [], + ...sameRouteReplayProvenance('ark-code-latest'), runtimeContext, }), ); @@ -13456,6 +13681,7 @@ describe('AiSdkBackend thinking persistence', () => { turnId: 'turn-current', text: 'follow up', context: [], + ...sameRouteReplayProvenance('mock-model-id'), runtimeContext, }), ); @@ -13566,6 +13792,7 @@ describe('AiSdkBackend thinking persistence', () => { turnId: 'turn-current', text: 'follow up', context: [], + ...sameRouteReplayProvenance('mock-model-id'), runtimeContext, }), ); @@ -13685,6 +13912,7 @@ describe('AiSdkBackend thinking persistence', () => { turnId: 'turn-current', text: 'follow up', context: [], + ...sameRouteReplayProvenance('mock-model-id'), runtimeContext, }), ); @@ -15691,6 +15919,7 @@ function header(permissionMode: SessionHeader['permissionMode'] = 'ask'): Sessio statusUpdatedAt: 1, hasUnread: false, backend: 'ai-sdk', + llmConnectionId: 'test-connection-id', llmConnectionSlug: 'anthropic-main', connectionLocked: true, model: 'claude-sonnet-4-5-20250929', @@ -15699,6 +15928,40 @@ function header(permissionMode: SessionHeader['permissionMode'] = 'ask'): Sessio }; } +function priorModelRunHeader(input: { + connectionId?: string; + modelId: string; + connectionSlug?: string; + runId?: string; +}): AgentRunHeader { + return { + runId: input.runId ?? 'run-prev', + sessionId: 'session-1', + turnId: 'turn-prev', + status: 'completed', + backendKind: 'ai-sdk', + ...(input.connectionId ? { llmConnectionId: input.connectionId } : {}), + llmConnectionSlug: input.connectionSlug ?? 'anthropic-main', + modelId: input.modelId, + cwd: '/tmp/maka', + permissionMode: 'ask', + createdAt: 1, + updatedAt: 2, + completedAt: 2, + }; +} + +function sameRouteReplayProvenance( + modelId: string, + runId = 'run-prev', +): Pick { + return { + runtimeContextRunHeaders: [ + priorModelRunHeader({ connectionId: 'test-connection-id', modelId, runId }), + ], + }; +} + function connection(): LlmConnection { return { slug: 'anthropic-main', diff --git a/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts b/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts index 2d37b9a955..de15d79322 100644 --- a/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts +++ b/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts @@ -433,22 +433,24 @@ describe('OpenAI-compatible product loops', () => { 'openai-chat', 131_072, ); + const sourceRun = { + runId: 'run-kimi-openai-recovered-tool-step', + invocationId: 'invocation-kimi-openai-recovered-tool-step', + sessionId, + turnId: previousTurnId, + status: 'completed', + backendKind: 'ai-sdk', + llmConnectionId: 'test-connection-id', + llmConnectionSlug: providerConnection.slug, + modelId: 'k3', + cwd: '/tmp/maka', + permissionMode: 'ask', + createdAt: 1, + updatedAt: 5, + completedAt: 5, + } satisfies AgentRunHeader; const recovered = backfillRuntimeEventsFromStoredMessages({ - run: { - runId: 'run-kimi-openai-recovered-tool-step', - invocationId: 'invocation-kimi-openai-recovered-tool-step', - sessionId, - turnId: previousTurnId, - status: 'completed', - backendKind: 'ai-sdk', - llmConnectionSlug: providerConnection.slug, - modelId: 'k3', - cwd: '/tmp/maka', - permissionMode: 'ask', - createdAt: 1, - updatedAt: 5, - completedAt: 5, - } satisfies AgentRunHeader, + run: sourceRun, messages: [ { type: 'user', @@ -509,7 +511,10 @@ describe('OpenAI-compatible product loops', () => { }); for await (const event of runtime.send( - currentTurn.sendInput({ runtimeContext: recovered.events }), + currentTurn.sendInput({ + runtimeContext: recovered.events, + runtimeContextRunHeaders: [sourceRun], + }), )) { currentTurn.record(event); } @@ -573,6 +578,22 @@ describe('OpenAI-compatible product loops', () => { 'openai-chat', 131_072, ); + const sourceRun = { + runId: firstTurn.anchor.runId, + invocationId: firstTurn.anchor.invocationId, + sessionId, + turnId: firstTurn.anchor.turnId, + status: 'completed', + backendKind: 'ai-sdk', + llmConnectionId: 'test-connection-id', + llmConnectionSlug: providerConnection.slug, + modelId: 'k3', + cwd: '/tmp/maka', + permissionMode: 'ask', + createdAt: firstTurn.anchor.ts, + updatedAt: firstTurn.anchor.ts + 1, + completedAt: firstTurn.anchor.ts + 1, + } satisfies AgentRunHeader; const createRuntime = () => createTestAiSdkBackend({ testProjectionArtifacts: true, @@ -603,21 +624,7 @@ describe('OpenAI-compatible product loops', () => { ); const recovered = backfillRuntimeEventsFromStoredMessages({ - run: { - runId: firstTurn.anchor.runId, - invocationId: firstTurn.anchor.invocationId, - sessionId, - turnId: firstTurn.anchor.turnId, - status: 'completed', - backendKind: 'ai-sdk', - llmConnectionSlug: providerConnection.slug, - modelId: 'k3', - cwd: '/tmp/maka', - permissionMode: 'ask', - createdAt: firstTurn.anchor.ts, - updatedAt: firstTurn.anchor.ts + 1, - completedAt: firstTurn.anchor.ts + 1, - } satisfies AgentRunHeader, + run: sourceRun, messages: storedMessages, newId: idGenerator(), now: monotonicClock(), @@ -633,7 +640,10 @@ describe('OpenAI-compatible product loops', () => { ); for await (const event of createRuntime().send( - secondTurn.sendInput({ runtimeContext: recovered.events }), + secondTurn.sendInput({ + runtimeContext: recovered.events, + runtimeContextRunHeaders: [sourceRun], + }), )) { secondTurn.record(event); } @@ -1196,6 +1206,7 @@ function header(providerType: LlmConnection['providerType'], model: string): Ses statusUpdatedAt: 1, hasUnread: false, backend: 'ai-sdk', + llmConnectionId: 'test-connection-id', llmConnectionSlug: providerType, connectionLocked: true, model, diff --git a/packages/runtime/src/__tests__/openai-codex-history-compactor.test.ts b/packages/runtime/src/__tests__/openai-codex-history-compactor.test.ts index af38785d32..1a368dbd50 100644 --- a/packages/runtime/src/__tests__/openai-codex-history-compactor.test.ts +++ b/packages/runtime/src/__tests__/openai-codex-history-compactor.test.ts @@ -21,23 +21,12 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import { extractOpenAiCodexCompactionState, - fitOpenAiCodexCompactionMessages, shouldFallbackFromOpenAiCodexHistoryCompaction, withOpenAiCodexHistoryCompactionFallback, } from '../openai-codex-history-compactor.js'; import { HistoryCompactSummarizerError } from '../history-compact-error.js'; import type { HistoryCompactSummaryInput } from '../ai-sdk-compaction-contract.js'; -test('preserves the provider-specific input-fitting export', () => { - assert.deepEqual( - fitOpenAiCodexCompactionMessages( - [{ role: 'user', content: [{ type: 'text', text: 'bounded history' }] }], - { maxInputEstimatedTokens: 1_000, charsPerToken: 1 }, - ), - [{ role: 'user', content: [{ type: 'text', text: 'bounded history' }] }], - ); -}); - describe('OpenAI Codex compaction fallback', () => { const input: HistoryCompactSummaryInput = { sessionId: 'session-1', diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 6390562560..fa7d22e808 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -72,7 +72,11 @@ import type { } from '@maka/core/runtime-event-store'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { SessionHeader, SessionSummary, StoredMessage, TurnRecord } from '@maka/core/session'; -import type { BackendSendInput, BackendStopMode } from '@maka/core/backend-types'; +import type { + BackendCompactHistoryInput, + BackendSendInput, + BackendStopMode, +} from '@maka/core/backend-types'; import { PlanConflictError, emptyPlanSessionState, type PlanStore } from '@maka/core/plan'; import { MockLanguageModelV4, simulateReadableStream } from 'ai/test'; import { createTestAiSdkBackend } from './execution-boundary-test-helpers.js'; @@ -3348,15 +3352,33 @@ describe('SessionManager manual compaction and quiescent session changes', () => newId: nextId(), now: nextNow(10_000), }); - const session = await manager.createSession(makeInput({ permissionMode: 'bypass' })); + const session = await manager.createSession( + makeInput({ permissionMode: 'bypass', llmConnectionId: 'connection-compact' }), + ); await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'hello' })); + const sourceRun = (await runStore.listSessionRuns(session.id)).find( + (run) => run.turnId === 'turn-1', + ); + assert.ok(sourceRun); runStore.operations = []; const events = await collectSessionEvents( manager.compactSession(session.id, { turnId: 'turn-compact' }), ); - assert.deepStrictEqual(compactCalls, [{ turnId: 'turn-compact', runtimeContextCount: 3 }]); + assert.deepStrictEqual(compactCalls, [ + { + turnId: 'turn-compact', + runtimeContextCount: 3, + sourceRoutes: [ + { + runId: sourceRun.runId, + connectionId: sourceRun.llmConnectionId, + modelId: sourceRun.modelId, + }, + ], + }, + ]); assert.deepStrictEqual( events.map((event) => event.type), ['token_usage', 'complete'], @@ -5139,14 +5161,21 @@ describe('SessionManager permission mode updates', () => { runtimeEventStore: runStore, toolBoundaryProtocol: 't1_after_preflight_v1', backends, - inspectContinuationSafety: inspectStableContinuationSafety, + childTools: [testTool('Read')], + inspectContinuationSafety: async () => ({ + workspaceIdentity: 'workspace-1', + backgroundOperationsSettled: true, + availableToolNames: ['Read'], + }), onContinuationLifecycleEvent: (event) => { lifecycleEvents.push(event); }, newId: nextId(), now: nextNow(6_550), }); - const session = await manager.createSession(makeInput()); + const session = await manager.createSession( + makeInput({ llmConnectionId: 'connection-continuation' }), + ); const header = await store.readHeader(session.id); const sourceRunId = 'source-run'; const sourceTurnId = 'source-turn'; @@ -5159,6 +5188,7 @@ describe('SessionManager permission mode updates', () => { status: 'failed', failureClass: 'runtime_interrupted', backendKind: header.backend, + llmConnectionId: header.llmConnectionId, llmConnectionSlug: header.llmConnectionSlug, modelId: header.model, cwd: header.cwd, @@ -5185,13 +5215,65 @@ describe('SessionManager permission mode updates', () => { content: { kind: 'text', text: 'continue safely' }, }, { - id: 'source-terminal', + id: 'source-thinking', sessionId: session.id, invocationId: sourceInvocationId, runId: sourceRunId, turnId: sourceTurnId, ts: 2, partial: false, + author: 'agent', + role: 'model', + content: { + kind: 'thinking', + text: 'same-route provider reasoning', + signature: 'same-route-signature', + }, + refs: { stepId: 'source-step' }, + }, + { + id: 'source-tool-call', + sessionId: session.id, + invocationId: sourceInvocationId, + runId: sourceRunId, + turnId: sourceTurnId, + ts: 3, + partial: false, + author: 'agent', + role: 'model', + content: { + kind: 'function_call', + id: 'source-read', + name: 'Read', + args: { path: 'package.json' }, + }, + refs: { stepId: 'source-step' }, + }, + { + id: 'source-tool-result', + sessionId: session.id, + invocationId: sourceInvocationId, + runId: sourceRunId, + turnId: sourceTurnId, + ts: 4, + partial: false, + author: 'tool', + role: 'tool', + content: { + kind: 'function_response', + id: 'source-read', + name: 'Read', + result: 'package contents', + }, + }, + { + id: 'source-terminal', + sessionId: session.id, + invocationId: sourceInvocationId, + runId: sourceRunId, + turnId: sourceTurnId, + ts: 5, + partial: false, author: 'system', role: 'system', status: 'failed', @@ -5208,7 +5290,7 @@ describe('SessionManager permission mode updates', () => { sourceWorkspaceIdentity: 'workspace-1', currentWorkspaceIdentity: 'workspace-1', backgroundOperationsSettled: true, - availableToolNames: [], + availableToolNames: ['Read'], }); assert.strictEqual(plan.disposition, 'continue'); if (!plan.continuation) throw new Error('expected continuation'); @@ -5237,6 +5319,20 @@ describe('SessionManager permission mode updates', () => { toolMode: 'code_mode', }); assert.strictEqual(backend?.sendInputs[0]?.toolMode, 'code_mode'); + assert.deepStrictEqual( + backend?.sendInputs[0]?.runtimeContextRunHeaders?.map((runHeader) => ({ + runId: runHeader.runId, + llmConnectionId: runHeader.llmConnectionId, + modelId: runHeader.modelId, + })), + [ + { + runId: sourceRunId, + llmConnectionId: header.llmConnectionId, + modelId: header.model, + }, + ], + ); const continuationEvents = await runStore.readRuntimeEvents( session.id, plan.continuation.runId, @@ -12000,15 +12096,24 @@ class CountingFinalTextBackend extends FinalTextTestBackend { class CompactingTestBackend extends TestBackend { constructor( ctx: BackendFactoryContext, - private readonly compactCalls: Array<{ turnId: string; runtimeContextCount: number }>, + private readonly compactCalls: Array<{ + turnId: string; + runtimeContextCount: number; + sourceRoutes?: Array<{ runId: string; connectionId?: string; modelId: string }>; + }>, ) { super(ctx); } - async compactHistory(input: { turnId: string; runtimeContext: readonly RuntimeEvent[] }) { + async compactHistory(input: BackendCompactHistoryInput) { this.compactCalls.push({ turnId: input.turnId, runtimeContextCount: input.runtimeContext.length, + sourceRoutes: (input.runtimeContextRunHeaders ?? []).map((run) => ({ + runId: run.runId, + ...(run.llmConnectionId ? { connectionId: run.llmConnectionId } : {}), + modelId: run.modelId, + })), }); return compactHistoryResult(); } diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index 0b0a08ff8d..66e7e9236d 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -198,6 +198,7 @@ export interface AgentRunBeginResult { export interface AgentRunOperationBeginResult { backend: AgentBackend; runtimeContext: RuntimeEvent[]; + runtimeContextRunHeaders: AgentRunHeader[]; startedAt: number; } @@ -655,7 +656,12 @@ export class AgentRun { : {}), ...(this.input.userInput.quotes ? { quotes: this.input.userInput.quotes } : {}), context: projectionContext, - ...(priorRuntimeContext ? { runtimeContext: priorRuntimeContext.events } : {}), + ...(priorRuntimeContext + ? { + runtimeContext: priorRuntimeContext.events, + runtimeContextRunHeaders: priorRuntimeContext.runs, + } + : {}), }), initialRuntimeEvent, }; @@ -680,6 +686,7 @@ export class AgentRun { return { backend: this.active.backend, runtimeContext: priorRuntimeContext?.events ?? [], + runtimeContextRunHeaders: priorRuntimeContext?.runs ?? [], startedAt, }; } diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 493a969b7b..7c5370a60b 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -210,6 +210,7 @@ import { buildRuntimeEventModelReplayPlan, buildSteeringEnvelope, collectToolActivityTurnIds, + compatibleProviderReasoningReplayEventIds, formatTextWithInlineRefs, steeringMessagesMissingFromBase, steeringModelMessage, @@ -3358,6 +3359,12 @@ export class AiSdkBackend implements AgentBackend { const priorRuntimeContext = input.runtimeContext.filter( (event) => event.turnId !== input.turnId, ); + const providerReasoningReplayEventIds = compatibleProviderReasoningReplayEventIds( + priorRuntimeContext, + input.runtimeContextRunHeaders, + this.input.header.llmConnectionId, + this.input.modelId, + ); const projectedMessages = await this.materializePriorMessages( scope.imageBudget, priorStored, @@ -3399,6 +3406,7 @@ export class AiSdkBackend implements AgentBackend { turnId: input.turnId, runId: scope.runId, runtimeContext: priorRuntimeContext, + runtimeContextRunHeaders: input.runtimeContextRunHeaders, }, automaticMemorySource ? { @@ -3528,6 +3536,7 @@ export class AiSdkBackend implements AgentBackend { plan, scope.imageBudget, projectedHistoryCompactCheckpoint, + providerReasoningReplayEventIds, ), gate: 'runtime_replay_text_only', diagnostics: plan.diagnostics, @@ -3551,6 +3560,7 @@ export class AiSdkBackend implements AgentBackend { degradedPlan, scope.imageBudget, projectedHistoryCompactCheckpoint, + providerReasoningReplayEventIds, ) : await materializeReplayFallback(), gate: input.continuation @@ -3569,6 +3579,7 @@ export class AiSdkBackend implements AgentBackend { plan, scope.imageBudget, projectedHistoryCompactCheckpoint, + providerReasoningReplayEventIds, ), gate: 'runtime_replay_provider_native', diagnostics: plan.diagnostics, @@ -3640,6 +3651,7 @@ export class AiSdkBackend implements AgentBackend { plan: RuntimeEventModelReplayPlan, budget: ProviderImageBudget, historyCompactCheckpoint?: HistoryCompactCheckpoint, + providerReasoningReplayEventIds?: ReadonlySet, ): Promise { type ToolCallItem = Extract; type ToolResultItem = Extract; @@ -3666,6 +3678,12 @@ export class AiSdkBackend implements AgentBackend { const replaySupport = this.modelAdapter.runtimeEventReplaySupport(); const reasoningReplay = (item: ThinkingItem): ReplayReasoning | undefined => { + if ( + providerReasoningReplayEventIds !== undefined && + !providerReasoningReplayEventIds.has(item.eventId) + ) { + return undefined; + } if (item.signature) { return replaySupport.signedThinking ? { diff --git a/packages/runtime/src/ai-sdk-compaction-contract.ts b/packages/runtime/src/ai-sdk-compaction-contract.ts index a969f03bc4..79fd3690ce 100644 --- a/packages/runtime/src/ai-sdk-compaction-contract.ts +++ b/packages/runtime/src/ai-sdk-compaction-contract.ts @@ -20,6 +20,7 @@ import type { RuntimeExecutionConnection } from '@maka/core/llm-connections'; import type { HistoryCompactRoute } from '@maka/core/model-call-attempt'; import type { RuntimeEvent } from '@maka/core/runtime-event'; +import type { AgentRunHeader } from '@maka/core/agent-run'; import type { ProviderRequestTracker } from './provider-request-telemetry.js'; import type { ActiveToolResultArchiveCandidate } from './active-tool-result-prune.js'; @@ -34,7 +35,12 @@ import type { ToolResultArchiveCapability } from './tool-result-archive-capabili export interface HistoryCompactSummaryInput { sessionId: string; turnId: string; - source: { foldedRuntimeEvents: RuntimeEvent[] }; + /** Run issuing this compaction; its events are same-route by construction. */ + runId?: string; + source: { + foldedRuntimeEvents: RuntimeEvent[]; + runHeaders?: readonly AgentRunHeader[]; + }; previousCheckpoint?: HistoryCompactCheckpoint; newlyFoldedRuntimeEvents?: RuntimeEvent[]; /** diff --git a/packages/runtime/src/ai-sdk-compaction.ts b/packages/runtime/src/ai-sdk-compaction.ts index cba17140a0..1145c28eb4 100644 --- a/packages/runtime/src/ai-sdk-compaction.ts +++ b/packages/runtime/src/ai-sdk-compaction.ts @@ -29,6 +29,7 @@ */ import type { RuntimeEvent } from '@maka/core/runtime-event'; +import type { AgentRunHeader } from '@maka/core/agent-run'; import type { BackendCompactHistoryInput, BackendCompactHistoryResult, @@ -327,7 +328,13 @@ export class AiSdkCompaction { await this.summarizeWithFailureCircuit(summarizer, { sessionId: this.sessionId, turnId: input.turnId, - source: { foldedRuntimeEvents: [...coveredRuntimeEvents] }, + runId: input.runId, + source: { + foldedRuntimeEvents: [...coveredRuntimeEvents], + ...(input.runtimeContextRunHeaders + ? { runHeaders: input.runtimeContextRunHeaders } + : {}), + }, newlyFoldedRuntimeEvents: [...newlyFoldedRuntimeEvents], ...(previousCheckpoint ? { previousCheckpoint } : {}), inputBudget: { @@ -441,15 +448,30 @@ export class AiSdkCompaction { summarizer: HistoryCompactSummarizer, input: HistoryCompactSummaryInput, ): Promise { + const foldedRunIds = new Set(input.source.foldedRuntimeEvents.map((event) => event.runId)); + const sourceRunRoutes = input.source.runHeaders + ?.filter((run) => foldedRunIds.has(run.runId)) + .map((run) => ({ + runId: run.runId, + connectionId: run.llmConnectionId, + modelId: run.modelId, + })) + .sort((left, right) => left.runId.localeCompare(right.runId)); const fingerprint = sha256( stableStringifyForSignature({ - version: 1, + version: 2, connection: this.input.connection, modelId: this.input.modelId, historyCompactRoute: this.input.historyCompactRoute, contextBudget: this.input.contextBudget, inputBudget: input.inputBudget, previousCheckpoint: input.previousCheckpoint, + currentRunEventIds: input.runId + ? input.source.foldedRuntimeEvents + .filter((event) => event.runId === input.runId) + .map((event) => event.id) + : [], + sourceRunRoutes, foldedRuntimeEvents: input.source.foldedRuntimeEvents, newlyFoldedRuntimeEvents: input.newlyFoldedRuntimeEvents, }), @@ -646,7 +668,12 @@ export class AiSdkCompaction { const priorContentEvents = (input.runtimeContext ?? []) .filter((event) => event.turnId !== input.turnId) .filter(isHistoryCompactContentEvent); - return new MidTurnCapacityCompactState(headAnchor, priorContentEvents, capacity); + return new MidTurnCapacityCompactState( + headAnchor, + priorContentEvents, + input.runtimeContextRunHeaders ?? [], + capacity, + ); } /** @@ -987,7 +1014,11 @@ export class AiSdkCompaction { return await this.summarizeWithFailureCircuit(summarizer, { sessionId: this.sessionId, turnId, - source: { foldedRuntimeEvents: [...coveredRuntimeEvents] }, + ...(input.origin.runId ? { runId: input.origin.runId } : {}), + source: { + foldedRuntimeEvents: [...coveredRuntimeEvents], + runHeaders: state.priorRunHeaders, + }, ...(previousCheckpoint ? { previousCheckpoint } : {}), newlyFoldedRuntimeEvents: [...newlyFoldedRuntimeEvents], inputBudget: { @@ -1562,6 +1593,7 @@ export class MidTurnCapacityCompactState { constructor( readonly headAnchor: RuntimeEvent, readonly priorContentEvents: readonly RuntimeEvent[], + readonly priorRunHeaders: readonly AgentRunHeader[], readonly capacity: ContextBudgetCapacity, ) {} } diff --git a/packages/runtime/src/model-history.ts b/packages/runtime/src/model-history.ts index 9d1be51abf..7147b43646 100644 --- a/packages/runtime/src/model-history.ts +++ b/packages/runtime/src/model-history.ts @@ -63,6 +63,7 @@ import { } from '@maka/core/runtime-event'; import { formatAttachmentResourceRef } from '@maka/core/attachments'; import type { AttachmentRef, QuoteRef } from '@maka/core/events'; +import type { AgentRunHeader } from '@maka/core/agent-run'; import type { ModelMessage, ToolResultOutput, @@ -78,6 +79,31 @@ import type { DurableToolResultProjection } from '@maka/core/durable-tool-result export const PROVIDER_REPLAY_PROJECTION_VERSION = 1; +/** + * Resolve the RuntimeEvents whose provider-owned reasoning may cross the + * current provider boundary. Route provenance remains on AgentRunHeader; + * current-run events are same-route by construction during mid-turn replay. + */ +export function compatibleProviderReasoningReplayEventIds( + events: readonly RuntimeEvent[], + runHeaders: readonly AgentRunHeader[] | undefined, + targetConnectionId: string | undefined, + targetModelId: string, + currentRunId?: string, +): ReadonlySet { + const compatibleRunIds = new Set(currentRunId ? [currentRunId] : []); + if (targetConnectionId && runHeaders) { + for (const run of runHeaders) { + if (run.llmConnectionId === targetConnectionId && run.modelId === targetModelId) { + compatibleRunIds.add(run.runId); + } + } + } + return new Set( + events.filter((event) => compatibleRunIds.has(event.runId)).map((event) => event.id), + ); +} + // ============================================================================ // Effective model-history sizing // ============================================================================ diff --git a/packages/runtime/src/openai-codex-history-compactor.ts b/packages/runtime/src/openai-codex-history-compactor.ts index 9d9e843dec..20cba4635b 100644 --- a/packages/runtime/src/openai-codex-history-compactor.ts +++ b/packages/runtime/src/openai-codex-history-compactor.ts @@ -32,16 +32,16 @@ import type { ModelMessage } from './model-protocol.js'; import { fitHistoryCompactMessages } from './history-compact-input-fit.js'; import { buildRuntimeEventModelReplayPlan, + compatibleProviderReasoningReplayEventIds, type RuntimeEventModelReplayItem, } from './model-history.js'; import { withProviderStreamTracking } from './provider-request-telemetry.js'; import { effectiveReplayToolResultOutput } from './durable-tool-result-projection.js'; import { providerFailureDiagnostic } from './provider-error-classification.js'; -export { fitHistoryCompactMessages as fitOpenAiCodexCompactionMessages } from './history-compact-input-fit.js'; - export interface BuildOpenAiCodexHistoryCompactorOptions { resolveModel: () => unknown; + connectionId?: string; connectionSlug: string; modelId: string; providerOptions?: Record; @@ -72,7 +72,17 @@ export function buildOpenAiCodexHistoryCompactor(options: BuildOpenAiCodexHistor const events = canContinuePrevious ? (input.newlyFoldedRuntimeEvents ?? []) : input.source.foldedRuntimeEvents; - const projectedMessages = openAiCodexCompactionMessages(events); + const providerReasoningReplayEventIds = compatibleProviderReasoningReplayEventIds( + events, + input.source.runHeaders, + options.connectionId, + options.modelId, + input.runId, + ); + const projectedMessages = openAiCodexCompactionMessages( + events, + providerReasoningReplayEventIds, + ); if (canContinuePrevious) { projectedMessages.unshift(historyCompactCheckpointToModelMessage(previous)); } @@ -174,7 +184,10 @@ function hasAbortCause(error: unknown): boolean { * Runtime persists tool calls/results before a step's reasoning/text closer; * grouping lets the compactor keep settled tool evidence before grounded text. */ -export function openAiCodexCompactionMessages(events: readonly RuntimeEvent[]): ModelMessage[] { +export function openAiCodexCompactionMessages( + events: readonly RuntimeEvent[], + providerReasoningReplayEventIds?: ReadonlySet, +): ModelMessage[] { type ToolCall = Extract; type ToolResult = Extract; type Thinking = Extract; @@ -215,6 +228,12 @@ export function openAiCodexCompactionMessages(events: readonly RuntimeEvent[]): continue; } if (item.kind === 'thinking') { + if ( + providerReasoningReplayEventIds !== undefined && + !providerReasoningReplayEventIds.has(item.eventId) + ) { + continue; + } if (item.stepId) step(item.stepId).reasoning.push(item); else timeline.push({ kind: 'thinking', item }); continue; diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index f2f048ecc8..ae54c30e18 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -880,6 +880,7 @@ export class RuntimeKernel implements RuntimeKernelLike { this.attachExecutionClaim(execution, run); yield* this.runAgentContinuation( continuation, + sessionRuns, run, execution, { @@ -1007,6 +1008,7 @@ export class RuntimeKernel implements RuntimeKernelLike { turnId: run.turnId, runId: run.runId, runtimeContext: begin.runtimeContext, + runtimeContextRunHeaders: begin.runtimeContextRunHeaders, }); if (run.isStopped()) return; const tokenUsageEvent: TokenUsageEvent = { @@ -1247,6 +1249,7 @@ export class RuntimeKernel implements RuntimeKernelLike { private async *runAgentContinuation( continuation: RuntimeContinuation, + runtimeContextRunHeaders: AgentRunHeader[], run: AgentRun, execution: PendingExecutionClaim, messageOwner?: RuntimeMessageRunIdentity, @@ -1329,6 +1332,7 @@ export class RuntimeKernel implements RuntimeKernelLike { text: '', context: [], runtimeContext: continuation.runtimeContext, + runtimeContextRunHeaders, continuation: continuationMetadata, }, onSessionEvent: async (sessionEvent, runtimeEvent) => { From d64cb65abedf887b629d9d636402cc5666b7a1e7 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 07:53:56 +0800 Subject: [PATCH 02/16] fix(runtime): replay Anthropic redacted thinking Requires the source-route replay gate established by the preceding commit. Generated-by: Codex --- .../computer-use-provider-protocol.test.ts | 138 ++++++++++++++++++ .../src/__tests__/model-adapter.test.ts | 29 ++++ packages/runtime/src/ai-sdk-backend.ts | 17 +++ packages/runtime/src/model-adapter.ts | 28 +++- 4 files changed, 211 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts b/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts index de15d79322..5cae778046 100644 --- a/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts +++ b/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts @@ -49,6 +49,98 @@ after(async () => { }); describe('Anthropic-compatible Computer Use product loops', () => { + test('replays same-route redacted thinking through the Anthropic SDK converter', async () => { + const sessionId = 'session-anthropic-redacted-replay'; + const firstTurn = createDurableTurnHarness({ + sessionId, + runId: 'run-prev', + turnId: 'turn-prev', + text: 'Inspect first.', + }); + const secondTurn = createDurableTurnHarness({ + sessionId, + runId: 'run-current', + turnId: 'turn-current', + text: 'Continue.', + }); + const requestBodies: Array> = []; + const server = await startJsonServer(async (request, response) => { + assert.equal(request.method, 'POST'); + assert.equal(request.url, '/v1/messages'); + requestBodies.push(JSON.parse(await readBody(request)) as Record); + if (requestBodies.length === 1) { + respondAnthropicRedactedStream( + response, + 'claude-sonnet-4-5-20250929', + 'opaque-redacted-thinking', + ); + } else { + respondAnthropicStream(response, 'claude-sonnet-4-5-20250929', 2, undefined); + } + }); + const providerConnection = connection('anthropic', server.url, 'claude-sonnet-4-5-20250929'); + const createRuntime = () => + createTestAiSdkBackend({ + sessionId, + header: { + ...header('anthropic', 'claude-sonnet-4-5-20250929'), + llmConnectionId: 'connection-anthropic', + }, + appendMessage: async () => {}, + connection: providerConnection, + apiKey: 'test-key', + modelId: 'claude-sonnet-4-5-20250929', + modelFactory: (input) => getAIModel(input), + tools: [], + maxSteps: 1, + loadTurnRuntimeEvents: async (turnId) => + [...firstTurn.ledger, ...secondTurn.ledger].filter((event) => event.turnId === turnId), + newId: idGenerator(), + now: monotonicClock(), + }); + const sourceRun = { + runId: 'run-prev', + sessionId, + turnId: 'turn-prev', + status: 'completed', + backendKind: 'ai-sdk', + llmConnectionId: 'connection-anthropic', + llmConnectionSlug: 'anthropic', + modelId: 'claude-sonnet-4-5-20250929', + cwd: '/tmp/maka', + permissionMode: 'bypass', + createdAt: 1, + updatedAt: 2, + completedAt: 2, + } satisfies AgentRunHeader; + for await (const event of createRuntime().send(firstTurn.sendInput())) firstTurn.record(event); + assert.ok( + firstTurn.ledger.some( + (event) => + event.content?.kind === 'thinking' && + isRecord(event.content.providerOptions?.anthropic) && + event.content.providerOptions.anthropic.redactedData === 'opaque-redacted-thinking', + ), + 'ModelAdapter metadata must survive the RuntimeEvent durability boundary', + ); + + for await (const event of createRuntime().send( + secondTurn.sendInput({ + runtimeContext: firstTurn.ledger, + runtimeContextRunHeaders: [sourceRun], + }), + )) { + secondTurn.record(event); + } + + assert.equal(requestBodies.length, 2); + assert.ok( + collectRecords(requestBodies[1]!.messages).some( + (block) => block.type === 'redacted_thinking' && block.data === 'opaque-redacted-thinking', + ), + ); + }); + for (const provider of [ { providerType: 'kimi-coding-plan', @@ -1191,6 +1283,52 @@ function respondOpenAiTextStream( response.end(); } +function respondAnthropicRedactedStream( + response: ServerResponse, + model: string, + redactedData: string, +) { + response.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + }); + const send = (event: string, data: unknown) => { + response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + }; + send('message_start', { + type: 'message_start', + message: { + id: 'msg-redacted', + type: 'message', + role: 'assistant', + model, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 10, output_tokens: 0 }, + }, + }); + send('content_block_start', { + type: 'content_block_start', + index: 0, + content_block: { type: 'redacted_thinking', data: redactedData }, + }); + send('content_block_stop', { type: 'content_block_stop', index: 0 }); + send('content_block_start', { + type: 'content_block_start', + index: 1, + content_block: { type: 'text', text: 'Inspection complete.' }, + }); + send('content_block_stop', { type: 'content_block_stop', index: 1 }); + send('message_delta', { + type: 'message_delta', + delta: { stop_reason: 'end_turn', stop_sequence: null }, + usage: { output_tokens: 5 }, + }); + send('message_stop', { type: 'message_stop' }); + response.end(); +} + function header(providerType: LlmConnection['providerType'], model: string): SessionHeader { return { id: `session-${providerType}`, diff --git a/packages/runtime/src/__tests__/model-adapter.test.ts b/packages/runtime/src/__tests__/model-adapter.test.ts index 003a28e53d..80b9afd998 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -66,6 +66,35 @@ describe('ModelAdapter stream and error normalization', () => { assert.equal(adapter.runtimeEventReplaySupport().signedThinking, true); }); + test('preserves Anthropic redacted thinking metadata at the model boundary', () => { + const adapter = new ModelAdapter({ + connection: { + slug: 'anthropic-main', + providerType: 'anthropic', + defaultModel: 'claude-sonnet-4-5-20250929', + }, + apiKey: 'anthropic-token', + modelId: 'claude-sonnet-4-5-20250929', + modelFactory: () => ({}), + newId: idGenerator(), + now: monotonicClock(), + }); + + assert.deepEqual( + adapter.translateChunk({ + type: 'reasoning-start', + providerMetadata: { anthropic: { redactedData: 'opaque-redacted-thinking' } }, + }), + [ + { + kind: 'thinking', + text: '', + providerOptions: { anthropic: { redactedData: 'opaque-redacted-thinking' } }, + }, + ], + ); + }); + test('supports unsigned-thinking replay on Kimi models using the OpenAI wire', () => { const adapter = new ModelAdapter({ connection: { diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 7c5370a60b..6bd29812f5 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -3695,6 +3695,23 @@ export class AiSdkBackend implements AgentBackend { } : undefined; } + const anthropic = item.providerOptions?.anthropic; + if ( + anthropic && + typeof anthropic === 'object' && + !Array.isArray(anthropic) && + typeof (anthropic as { redactedData?: unknown }).redactedData === 'string' + ) { + return replaySupport.signedThinking + ? { + part: { + type: 'reasoning' as const, + text: item.text, + providerOptions: item.providerOptions, + }, + } + : undefined; + } if ( typeof replaySupport.responsesReasoning === 'object' && replaySupport.responsesReasoning.kind === 'plaintext-item' diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index b1cc2a60b2..2393d06113 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -827,6 +827,19 @@ function reasoningSignatureFromChunk(chunk: AiSdkStreamChunk): string | undefine return typeof signature === 'string' && signature.length > 0 ? signature : undefined; } +function anthropicRedactedThinkingProviderOptionsFromChunk( + chunk: AiSdkStreamChunk, +): NonNullable | undefined { + const meta = chunk.providerMetadata; + if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return undefined; + const anthropic = (meta as { anthropic?: unknown }).anthropic; + if (!anthropic || typeof anthropic !== 'object' || Array.isArray(anthropic)) return undefined; + const redactedData = (anthropic as { redactedData?: unknown }).redactedData; + return typeof redactedData === 'string' + ? (meta as NonNullable) + : undefined; +} + function openAiResponsesReasoningProviderOptionsFromChunk( chunk: AiSdkStreamChunk, runtime: ResolvedModelRuntime, @@ -962,7 +975,20 @@ function translateChunk( switch (chunk.type) { case 'reasoning-start': { const reasoningItemId = plaintextSummaryItemIdFromChunk(chunk, runtime); - return reasoningItemId ? [{ kind: 'thinking', text: '', reasoningItemId }] : []; + const redactedThinkingProviderOptions = + anthropicRedactedThinkingProviderOptionsFromChunk(chunk); + return reasoningItemId || redactedThinkingProviderOptions + ? [ + { + kind: 'thinking', + text: '', + ...(redactedThinkingProviderOptions + ? { providerOptions: redactedThinkingProviderOptions } + : {}), + ...(reasoningItemId ? { reasoningItemId } : {}), + }, + ] + : []; } case 'text-start': return [{ kind: 'text-start' }]; From 9b4ec8b8c07a60bbaa66cf9d579184b0bd6b8d6e Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 07:54:01 +0800 Subject: [PATCH 03/16] fix(runtime): replay Copilot chat reasoning Requires the source-route replay gate established by the first commit in this series. Generated-by: Codex --- .../src/__tests__/ai-sdk-backend.test.ts | 95 +++++++++++++ .../computer-use-provider-protocol.test.ts | 128 +++++++++++++++++- packages/runtime/src/model-factory.ts | 11 +- packages/runtime/src/model-runtime.ts | 7 +- 4 files changed, 237 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index bc09bddbcf..629478cce6 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -6757,6 +6757,101 @@ describe('AiSdkBackend model history', () => { assert.match(promptJson, /legacy visible answer/); }); + test('drops cross-model Copilot reasoning while preserving text and tools', async () => { + const model = completionModel(); + const copilotConnection: LlmConnection = { + ...connection(), + slug: 'github-copilot', + providerType: 'github-copilot', + defaultModel: 'gpt-5.4', + models: [{ id: 'gpt-5.4', apiProtocol: 'openai-chat' }], + }; + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: { + ...header(), + llmConnectionId: 'connection-copilot', + llmConnectionSlug: 'github-copilot', + model: 'gpt-5.4', + }, + appendMessage: async () => {}, + connection: copilotConnection, + apiKey: 'sk-test', + modelId: 'gpt-5.4', + modelFactory: () => model, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'continue', + context: [], + runtimeContextRunHeaders: [ + priorModelRunHeader({ + connectionId: 'connection-copilot', + connectionSlug: 'github-copilot', + modelId: 'gpt-5.5', + }), + ], + runtimeContext: [ + runtimeEvent({ + id: 'rt-thinking', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'thinking', + text: 'copilot provider reasoning', + providerOptions: { + maka: { openAiChatReasoningField: 'reasoning_content' }, + }, + }, + }), + runtimeEvent({ + id: 'rt-call', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'tool-1', + name: 'Read', + args: { path: 'package.json' }, + }, + }), + runtimeEvent({ + id: 'rt-result', + turnId: 'turn-prev', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'tool-1', + name: 'Read', + result: 'copilot file contents', + }, + }), + runtimeTextEvent({ + id: 'rt-a', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + text: 'copilot visible answer', + }), + ], + }), + ); + + const promptJson = JSON.stringify(compactPrompt(model)); + assert.equal(promptJson.includes('copilot provider reasoning'), false); + assert.match(promptJson, /copilot visible answer/); + assert.match(promptJson, /"toolCallId":"tool-1"/); + assert.match(promptJson, /copilot file contents/); + }); + test('keeps same-route OpenAI Responses reasoning replay', async () => { const model = completionModel(); const openAiConnection: LlmConnection = { diff --git a/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts b/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts index 5cae778046..bb1a15fef7 100644 --- a/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts +++ b/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts @@ -21,6 +21,7 @@ import assert from 'node:assert/strict'; import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { after, describe, test } from 'node:test'; import type { AgentRunHeader } from '@maka/core/agent-run'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { LlmConnection } from '@maka/core/llm-connections'; @@ -421,6 +422,122 @@ describe('Anthropic-compatible Computer Use product loops', () => { }); describe('OpenAI-compatible product loops', () => { + test('github-copilot replays same-route reasoning_content on its OpenAI Chat wire', async () => { + const sessionId = 'session-github-copilot-reasoning-replay'; + const currentTurn = createDurableTurnHarness({ + sessionId, + runId: 'run-current', + turnId: 'turn-current', + text: 'Continue.', + }); + const requestBodies: Array> = []; + const server = await startJsonServer(async (request, response) => { + assert.equal(request.method, 'POST'); + assert.equal(request.url, '/v1/chat/completions'); + requestBodies.push(JSON.parse(await readBody(request)) as Record); + respondOpenAiTextStream(response, 'gpt-5.4', 1, 'reasoning_content', 'next step'); + }); + const providerConnection = connection( + 'github-copilot', + `${server.url}/v1`, + 'gpt-5.4', + 'openai-chat', + ); + const runtime = createTestAiSdkBackend({ + sessionId, + header: { + ...header('github-copilot', 'gpt-5.4'), + llmConnectionId: 'connection-copilot', + }, + appendMessage: async () => {}, + connection: providerConnection, + apiKey: 'test-key', + modelId: 'gpt-5.4', + modelFactory: (input) => getAIModel(input), + tools: [], + maxSteps: 1, + loadTurnRuntimeEvents: currentTurn.loadTurnRuntimeEvents, + newId: idGenerator(), + now: monotonicClock(), + }); + const sourceRun = { + runId: 'run-prev', + sessionId, + turnId: 'turn-prev', + status: 'completed', + backendKind: 'ai-sdk', + llmConnectionId: 'connection-copilot', + llmConnectionSlug: 'github-copilot', + modelId: 'gpt-5.4', + cwd: '/tmp/maka', + permissionMode: 'bypass', + createdAt: 1, + updatedAt: 2, + completedAt: 2, + } satisfies AgentRunHeader; + const priorEvents = [ + { + id: 'rt-user-prev', + invocationId: 'inv-prev', + runId: 'run-prev', + sessionId, + turnId: 'turn-prev', + ts: 1, + partial: false, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'Inspect first.' }, + }, + { + id: 'rt-thinking-prev', + invocationId: 'inv-prev', + runId: 'run-prev', + sessionId, + turnId: 'turn-prev', + ts: 2, + partial: false, + role: 'model', + author: 'agent', + content: { + kind: 'thinking', + text: 'copilot reasoning', + providerOptions: { maka: { openAiChatReasoningField: 'reasoning_content' } }, + }, + refs: { providerEventId: 'step-prev' }, + }, + { + id: 'rt-text-prev', + invocationId: 'inv-prev', + runId: 'run-prev', + sessionId, + turnId: 'turn-prev', + ts: 3, + partial: false, + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'Inspection complete.' }, + refs: { providerEventId: 'step-prev' }, + }, + ] satisfies RuntimeEvent[]; + + for await (const event of runtime.send( + currentTurn.sendInput({ + runtimeContext: priorEvents, + runtimeContextRunHeaders: [sourceRun], + }), + )) { + currentTurn.record(event); + } + + assert.equal(requestBodies.length, 1); + const replayedAssistant = (requestBodies[0]!.messages as unknown[]).find( + (message) => isRecord(message) && message.role === 'assistant', + ); + assert.ok(replayedAssistant && isRecord(replayedAssistant)); + assert.equal(replayedAssistant.reasoning_content, 'copilot reasoning'); + assert.equal(replayedAssistant.content, 'Inspection complete.'); + }); + for (const provider of [ { providerType: 'deepseek', @@ -434,6 +551,12 @@ describe('OpenAI-compatible product loops', () => { responseField: 'reasoning_content', requestField: 'reasoning', }, + { + providerType: 'github-copilot', + modelId: 'gpt-5.4', + responseField: 'reasoning', + requestField: 'reasoning', + }, ] as const) { test(`${provider.providerType} replays observed reasoning as its declared request field`, async () => { const sessionId = `session-${provider.providerType}-openai-chat`; @@ -493,7 +616,10 @@ describe('OpenAI-compatible product loops', () => { message.tool_calls.some((toolCall) => isRecord(toolCall) && toolCall.id === 'call-1'), ); assert.ok(replayedAssistant && isRecord(replayedAssistant)); - assert.equal(replayedAssistant[provider.requestField], 'reasoning-step-1'); + assert.equal( + replayedAssistant[provider.requestField], + provider.responseField === 'reasoning' ? '' : 'reasoning-step-1', + ); assert.equal( replayedAssistant[ provider.requestField === 'reasoning' ? 'reasoning_content' : 'reasoning' diff --git a/packages/runtime/src/model-factory.ts b/packages/runtime/src/model-factory.ts index ba3e602519..43080a6d21 100644 --- a/packages/runtime/src/model-factory.ts +++ b/packages/runtime/src/model-factory.ts @@ -138,11 +138,20 @@ export function getAIModel(input: ModelFactoryInput): LanguageModelV4 { fetch: requestFetch, }).chat(modelId); } + if (reasoningReplay.kind !== 'openai-chat-plaintext') { + throw new Error('Copilot OpenAI Chat wire requires plaintext reasoning replay'); + } + const reasoningTransport = createOpenAiChatReasoningTransport( + requestFetch, + openAiChatReasoningTransportState ?? + createOpenAiChatReasoningTransportState(reasoningReplay.requestField), + ); return createOpenAICompatible({ name: 'github-copilot', apiKey, baseURL, - fetch: requestFetch, + fetch: reasoningTransport.fetch, + transformRequestBody: reasoningTransport.transformRequestBody, }).chatModel(modelId); } diff --git a/packages/runtime/src/model-runtime.ts b/packages/runtime/src/model-runtime.ts index bd8d249f13..ffa097f6b8 100644 --- a/packages/runtime/src/model-runtime.ts +++ b/packages/runtime/src/model-runtime.ts @@ -244,11 +244,14 @@ function reasoningReplayContract( case 'openai-responses': return { kind: 'responses', contract: responsesContract(adapter) }; case 'openai-chat': - return adapter.kind === 'openai-compatible' + return adapter.kind === 'openai-compatible' || adapter.kind === 'github-copilot' ? { kind: 'openai-chat-plaintext', requestField: - adapter.replayAssistantReasoningAs === 'reasoning' ? 'reasoning' : 'observed', + adapter.kind === 'openai-compatible' && + adapter.replayAssistantReasoningAs === 'reasoning' + ? 'reasoning' + : 'observed', } : { kind: 'none' }; case 'google-generate': From b9473d0bada01d60761b4685ce52ec5f41496717 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 11:31:15 +0800 Subject: [PATCH 04/16] fix(runtime): bind native checkpoints to connection identity Generated-by: Codex --- .../src/server/execution-model-composition.ts | 7 +- .../src/__tests__/ai-sdk-backend.test.ts | 8 +- .../src/__tests__/context-budget.test.ts | 2 +- .../src/__tests__/conversation-copy.test.ts | 2 +- .../history-compact-checkpoint.test.ts | 74 ++++++++++++++++--- .../history-compact-summarizer.test.ts | 2 +- .../mid-turn-capacity-backend.test.ts | 3 +- .../openai-codex-history-compactor.test.ts | 2 +- .../overflow-reactive-recovery.test.ts | 3 +- packages/runtime/src/ai-sdk-backend.ts | 2 + packages/runtime/src/ai-sdk-compaction.ts | 5 ++ .../runtime/src/history-compact-checkpoint.ts | 15 ++-- .../src/openai-codex-history-compactor.ts | 19 +++-- 13 files changed, 107 insertions(+), 37 deletions(-) diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index a856e425ff..318823d4e1 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -194,12 +194,11 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom providerOptions, }); const summarizeHistoryCompact = - target.connection.providerType === 'openai-codex' + target.connection.providerType === 'openai-codex' && input.context.header.llmConnectionId ? withOpenAiCodexHistoryCompactionFallback( buildOpenAiCodexHistoryCompactor({ resolveModel: resolveHistoryCompactModel, connectionId: input.context.header.llmConnectionId, - connectionSlug: target.connection.slug, modelId: target.model, providerOptions, }), @@ -207,7 +206,9 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom ) : textHistorySummarizer; const historyCompactRoute = - target.connection.providerType === 'openai-codex' ? 'provider_native' : 'text_summary'; + target.connection.providerType === 'openai-codex' && input.context.header.llmConnectionId + ? 'provider_native' + : 'text_summary'; let telemetryDrainRequested = false; const persistTelemetry = async (operation: () => Promise): Promise => { try { diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 629478cce6..d91e9f611e 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -6130,7 +6130,7 @@ describe('AiSdkBackend model history', () => { coveredRuntimeEvents: covered, providerState: { kind: 'openai_codex_remote_v2', - connectionSlug: codexConnection.slug, + connectionId: 'test-connection-id', modelId: 'mock-model-id', itemId: 'cmp_replay', encryptedContent: 'CODEX_ENCRYPTED_REPLAY_STATE', @@ -6199,7 +6199,7 @@ describe('AiSdkBackend model history', () => { coveredRuntimeEvents: covered, providerState: { kind: 'openai_codex_remote_v2', - connectionSlug: codexConnection.slug, + connectionId: 'test-connection-id', modelId: 'mock-model-id', itemId: 'cmp_full_replay', encryptedContent: 'CODEX_FULL_ENCRYPTED_STATE', @@ -6292,7 +6292,7 @@ describe('AiSdkBackend model history', () => { coveredRuntimeEvents: covered, providerState: { kind: 'openai_codex_remote_v2', - connectionSlug: codexConnection.slug, + connectionId: 'test-connection-id', modelId: 'mock-model-id', itemId: 'cmp_model_switch', encryptedContent: 'CODEX_MODEL_SWITCH_ENCRYPTED_STATE', @@ -6386,7 +6386,7 @@ describe('AiSdkBackend model history', () => { coveredRuntimeEvents: covered, providerState: { kind: 'openai_codex_remote_v2', - connectionSlug: codexConnection.slug, + connectionId: 'test-connection-id', modelId: 'different-model', itemId: 'cmp_wrong_model', encryptedContent: 'CODEX_WRONG_MODEL_STATE', diff --git a/packages/runtime/src/__tests__/context-budget.test.ts b/packages/runtime/src/__tests__/context-budget.test.ts index 25389b61dc..26ada66d1b 100644 --- a/packages/runtime/src/__tests__/context-budget.test.ts +++ b/packages/runtime/src/__tests__/context-budget.test.ts @@ -58,7 +58,7 @@ test('checkpoint replay uses the canonical ledger before stale tool results are charsPerToken: 1, providerState: { kind: 'openai_codex_remote_v2', - connectionSlug: 'codex', + connectionId: 'connection-codex', modelId: 'gpt-test', itemId: 'compact-item', encryptedContent: 'encrypted', diff --git a/packages/runtime/src/__tests__/conversation-copy.test.ts b/packages/runtime/src/__tests__/conversation-copy.test.ts index 3852ee464f..ba6c5f8103 100644 --- a/packages/runtime/src/__tests__/conversation-copy.test.ts +++ b/packages/runtime/src/__tests__/conversation-copy.test.ts @@ -1924,7 +1924,7 @@ test('conversation copy clones one terminal Runtime ledger with new owned identi coveredRuntimeEvents: sourceEvents.filter(isHistoryCompactContentEvent), providerState: { kind: 'openai_codex_remote_v2', - connectionSlug: 'codex-source', + connectionId: 'connection-codex-source', modelId: 'gpt-5-codex', itemId: 'cmp-source', encryptedContent: 'OPAQUE_SOURCE_COMPACTION_STATE', diff --git a/packages/runtime/src/__tests__/history-compact-checkpoint.test.ts b/packages/runtime/src/__tests__/history-compact-checkpoint.test.ts index f9b9e54c52..5aa6a5aafa 100644 --- a/packages/runtime/src/__tests__/history-compact-checkpoint.test.ts +++ b/packages/runtime/src/__tests__/history-compact-checkpoint.test.ts @@ -55,13 +55,52 @@ const STRUCTURED_SUMMARY = [ ].join('\n'); describe('history compact checkpoint', () => { + test('rejects provider-native state from a recreated same-slug connection', () => { + const providerState = { + kind: 'openai_codex_remote_v2' as const, + connectionId: 'connection-a', + modelId: 'gpt-5.3-codex', + itemId: 'cmp_123', + encryptedContent: 'encrypted-state', + }; + const checkpoint = buildHistoryCompactCheckpoint({ + sessionId: 'session-1', + coveredRuntimeEvents: [textEvent(0), textEvent(1)], + providerState, + }); + const recreatedConnection = { + providerType: 'openai-codex', + slug: 'codex-subscription', + connectionId: 'connection-b', + }; + + assert.equal( + canReplayHistoryCompactCheckpointForModel( + checkpoint, + recreatedConnection, + recreatedConnection.connectionId, + 'gpt-5.3-codex', + ), + false, + ); + assert.equal( + canContinueHistoryCompactCheckpointForModel( + checkpoint, + recreatedConnection, + recreatedConnection.connectionId, + 'gpt-5.3-codex', + ), + false, + ); + }); + test('persists provider-native state as a V3 checkpoint bound to one Codex model', () => { const checkpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [textEvent(0), textEvent(1)], providerState: { kind: 'openai_codex_remote_v2', - connectionSlug: 'codex-subscription', + connectionId: 'connection-a', modelId: 'gpt-5.3-codex', itemId: 'cmp_123', encryptedContent: 'encrypted-state', @@ -75,7 +114,8 @@ describe('history compact checkpoint', () => { assert.equal( canReplayHistoryCompactCheckpointForModel( checkpoint, - { providerType: 'openai-codex', slug: 'codex-subscription' }, + { providerType: 'openai-codex' }, + 'connection-a', 'gpt-5.3-codex', ), true, @@ -83,7 +123,8 @@ describe('history compact checkpoint', () => { assert.equal( canReplayHistoryCompactCheckpointForModel( checkpoint, - { providerType: 'openai-codex', slug: 'other-codex-account' }, + { providerType: 'openai-codex' }, + 'connection-b', 'gpt-5.3-codex', ), false, @@ -91,7 +132,8 @@ describe('history compact checkpoint', () => { assert.equal( canContinueHistoryCompactCheckpointForModel( checkpoint, - { providerType: 'openai-codex', slug: 'codex-subscription' }, + { providerType: 'openai-codex' }, + 'connection-a', 'gpt-5.3-codex', ), true, @@ -99,7 +141,8 @@ describe('history compact checkpoint', () => { assert.equal( canContinueHistoryCompactCheckpointForModel( checkpoint, - { providerType: 'openai', slug: 'openai-api' }, + { providerType: 'openai' }, + 'connection-a', 'gpt-5.3-codex', ), false, @@ -107,7 +150,8 @@ describe('history compact checkpoint', () => { assert.equal( canReplayHistoryCompactCheckpointForModel( checkpoint, - { providerType: 'openai', slug: 'openai-api' }, + { providerType: 'openai' }, + 'connection-a', 'gpt-5.3-codex', ), false, @@ -133,7 +177,7 @@ describe('history compact checkpoint', () => { coveredRuntimeEvents: [textEvent(0)], providerState: { kind: 'openai_codex_remote_v2', - connectionSlug: 'codex-subscription', + connectionId: 'connection-a', modelId: 'gpt-5.3-codex', itemId: 'cmp_123', encryptedContent: 'encrypted-state', @@ -151,6 +195,14 @@ describe('history compact checkpoint', () => { validateHistoryCompactCheckpointShape({ ...checkpoint, summary: 'opaque state leaked here' }), false, ); + const { connectionId: _connectionId, ...legacyProviderState } = checkpoint.providerState; + assert.equal( + validateHistoryCompactCheckpointShape({ + ...checkpoint, + providerState: { ...legacyProviderState, connectionSlug: 'codex-subscription' }, + }), + false, + ); const v2 = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [textEvent(0)], @@ -161,7 +213,8 @@ describe('history compact checkpoint', () => { assert.equal( canContinueHistoryCompactCheckpointForModel( v2, - { providerType: 'openai', slug: 'openai-api' }, + { providerType: 'openai' }, + 'connection-a', 'gpt-5.3-codex', ), true, @@ -169,7 +222,8 @@ describe('history compact checkpoint', () => { assert.equal( canContinueHistoryCompactCheckpointForModel( v2, - { providerType: 'openai-codex', slug: 'codex-subscription' }, + { providerType: 'openai-codex' }, + 'connection-a', 'gpt-5.3-codex', ), false, @@ -433,7 +487,7 @@ describe('history compact checkpoint', () => { coveredRuntimeEvents: [textEvent(0), textEvent(1)], providerState: { kind: 'openai_codex_remote_v2', - connectionSlug: 'codex-subscription', + connectionId: 'connection-a', modelId: 'gpt-5.3-codex', itemId: 'cmp_durable', encryptedContent: 'durable-encrypted-state', diff --git a/packages/runtime/src/__tests__/history-compact-summarizer.test.ts b/packages/runtime/src/__tests__/history-compact-summarizer.test.ts index 0f89fdd084..2759154349 100644 --- a/packages/runtime/src/__tests__/history-compact-summarizer.test.ts +++ b/packages/runtime/src/__tests__/history-compact-summarizer.test.ts @@ -1413,7 +1413,7 @@ describe('buildLlmHistorySummarizer', () => { coveredRuntimeEvents: [old], providerState: { kind: 'openai_codex_remote_v2', - connectionSlug: 'codex-subscription', + connectionId: 'connection-codex', modelId: 'gpt-5.3-codex', itemId: 'cmp_123', encryptedContent: 'opaque-state', diff --git a/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts b/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts index 3342589670..ce4bc494c6 100644 --- a/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts +++ b/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts @@ -504,7 +504,7 @@ function buildFixture(options: MidTurnFixtureOptions = {}): MidTurnFixture { const summary = options.providerNative ? ({ kind: 'openai_codex_remote_v2', - connectionSlug: 'codex-subscription', + connectionId: 'test-connection-id', modelId: 'mock-model-id', itemId: 'cmp_mid_turn', encryptedContent: 'MID_TURN_ENCRYPTED_STATE', @@ -1745,6 +1745,7 @@ function header(): SessionHeader { statusUpdatedAt: 1, hasUnread: false, backend: 'ai-sdk', + llmConnectionId: 'test-connection-id', llmConnectionSlug: 'anthropic-main', connectionLocked: true, model: 'mock-model-id', diff --git a/packages/runtime/src/__tests__/openai-codex-history-compactor.test.ts b/packages/runtime/src/__tests__/openai-codex-history-compactor.test.ts index 1a368dbd50..b707be8eb4 100644 --- a/packages/runtime/src/__tests__/openai-codex-history-compactor.test.ts +++ b/packages/runtime/src/__tests__/openai-codex-history-compactor.test.ts @@ -105,7 +105,7 @@ describe('OpenAI Codex compaction output', () => { ), { kind: 'openai_codex_remote_v2', - connectionSlug: 'codex-subscription', + connectionId: 'codex-subscription', modelId: 'gpt-5.3-codex', itemId: 'item-1', encryptedContent: 'encrypted-1', diff --git a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts index b726de0dd3..e95848a9a2 100644 --- a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts +++ b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts @@ -517,7 +517,7 @@ function buildReactiveFixture(options: ReactiveFixtureOptions): ReactiveFixture return options.providerNative ? ({ kind: 'openai_codex_remote_v2', - connectionSlug: 'codex-subscription', + connectionId: 'test-connection-id', modelId: 'mock-model-id', itemId: 'cmp_reactive', encryptedContent: 'REACTIVE_ENCRYPTED_STATE', @@ -1825,6 +1825,7 @@ function header(): SessionHeader { statusUpdatedAt: 1, hasUnread: false, backend: 'ai-sdk', + llmConnectionId: 'test-connection-id', llmConnectionSlug: 'anthropic-main', connectionLocked: true, model: 'mock-model-id', diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 6bd29812f5..ce04dbc046 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -1112,6 +1112,7 @@ export class AiSdkBackend implements AgentBackend { this.compaction = new AiSdkCompaction({ input, sessionId: this.sessionId, + targetConnectionId: input.header.llmConnectionId, now: this.now, modelAdapter: this.modelAdapter, createProviderRequestTracker: (trackerInput) => @@ -1778,6 +1779,7 @@ export class AiSdkBackend implements AgentBackend { canContinueHistoryCompactCheckpointForModel( checkpoint, this.input.connection, + this.input.header.llmConnectionId, this.input.modelId, ) ? checkpoint diff --git a/packages/runtime/src/ai-sdk-compaction.ts b/packages/runtime/src/ai-sdk-compaction.ts index 1145c28eb4..da96f4b0c6 100644 --- a/packages/runtime/src/ai-sdk-compaction.ts +++ b/packages/runtime/src/ai-sdk-compaction.ts @@ -153,6 +153,7 @@ export interface AutomaticMemoryCompactionDecision { export interface AiSdkCompactionDeps { input: AiSdkCompactionCapabilities; sessionId: string; + targetConnectionId: string | undefined; now: () => number; modelAdapter: ModelAdapter; /** @@ -183,6 +184,7 @@ export interface AiSdkCompactionDeps { export class AiSdkCompaction { private readonly input: AiSdkCompactionCapabilities; private readonly sessionId: string; + private readonly targetConnectionId: string | undefined; private readonly now: () => number; private readonly modelAdapter: ModelAdapter; private readonly createProviderRequestTracker: (input: { @@ -212,6 +214,7 @@ export class AiSdkCompaction { constructor(deps: AiSdkCompactionDeps) { this.input = deps.input; this.sessionId = deps.sessionId; + this.targetConnectionId = deps.targetConnectionId; this.now = deps.now; this.modelAdapter = deps.modelAdapter; this.createProviderRequestTracker = deps.createProviderRequestTracker; @@ -257,6 +260,7 @@ export class AiSdkCompaction { canContinueHistoryCompactCheckpointForModel( loaded, this.input.connection, + this.targetConnectionId, this.input.modelId, ) ) { @@ -565,6 +569,7 @@ export class AiSdkCompaction { canReplayHistoryCompactCheckpointForModel( loadedCheckpoint, this.input.connection, + this.targetConnectionId, this.input.modelId, ) ) { diff --git a/packages/runtime/src/history-compact-checkpoint.ts b/packages/runtime/src/history-compact-checkpoint.ts index 0234f278d0..21fc17ee00 100644 --- a/packages/runtime/src/history-compact-checkpoint.ts +++ b/packages/runtime/src/history-compact-checkpoint.ts @@ -120,7 +120,7 @@ export interface TextHistoryCompactCheckpoint extends HistoryCompactCheckpointBa export interface OpenAiCodexRemoteCompactState { kind: 'openai_codex_remote_v2'; - connectionSlug: string; + connectionId: string; modelId: string; itemId: string; encryptedContent: string; @@ -402,13 +402,15 @@ export function isTextHistoryCompactCheckpoint( export function canReplayHistoryCompactCheckpointForModel( checkpoint: HistoryCompactCheckpoint, - connection: { providerType: string; slug: string }, + connection: { providerType: string }, + connectionId: string | undefined, modelId: string, ): boolean { if (!isProviderHistoryCompactCheckpoint(checkpoint)) return true; return ( connection.providerType === 'openai-codex' && - checkpoint.providerState.connectionSlug === connection.slug && + connectionId !== undefined && + checkpoint.providerState.connectionId === connectionId && checkpoint.providerState.modelId === modelId ); } @@ -416,13 +418,14 @@ export function canReplayHistoryCompactCheckpointForModel( /** Whether this model's configured compactor can roll forward from this checkpoint value. */ export function canContinueHistoryCompactCheckpointForModel( checkpoint: HistoryCompactCheckpoint, - connection: { providerType: string; slug: string }, + connection: { providerType: string }, + connectionId: string | undefined, modelId: string, ): boolean { if (isTextHistoryCompactCheckpoint(checkpoint)) { return connection.providerType !== 'openai-codex'; } - return canReplayHistoryCompactCheckpointForModel(checkpoint, connection, modelId); + return canReplayHistoryCompactCheckpointForModel(checkpoint, connection, connectionId, modelId); } export function historyCompactCheckpointToRuntimeEvent( @@ -802,7 +805,7 @@ function validHistoryCompactProviderState(value: unknown): value is HistoryCompa const state = value as Partial; return ( state.kind === 'openai_codex_remote_v2' && - nonEmpty(state.connectionSlug) && + nonEmpty(state.connectionId) && nonEmpty(state.modelId) && nonEmpty(state.itemId) && nonEmpty(state.encryptedContent) diff --git a/packages/runtime/src/openai-codex-history-compactor.ts b/packages/runtime/src/openai-codex-history-compactor.ts index 20cba4635b..3287486ac6 100644 --- a/packages/runtime/src/openai-codex-history-compactor.ts +++ b/packages/runtime/src/openai-codex-history-compactor.ts @@ -23,6 +23,7 @@ import type { } from './ai-sdk-compaction-contract.js'; import { HistoryCompactSummarizerError } from './history-compact-error.js'; import { + canContinueHistoryCompactCheckpointForModel, historyCompactCheckpointToModelMessage, isProviderHistoryCompactCheckpoint, type HistoryCompactProviderState, @@ -41,8 +42,7 @@ import { providerFailureDiagnostic } from './provider-error-classification.js'; export interface BuildOpenAiCodexHistoryCompactorOptions { resolveModel: () => unknown; - connectionId?: string; - connectionSlug: string; + connectionId: string; modelId: string; providerOptions?: Record; } @@ -66,9 +66,12 @@ export function buildOpenAiCodexHistoryCompactor(options: BuildOpenAiCodexHistor const canContinuePrevious = previous && isProviderHistoryCompactCheckpoint(previous) && - previous.providerState.kind === 'openai_codex_remote_v2' && - previous.providerState.connectionSlug === options.connectionSlug && - previous.providerState.modelId === options.modelId; + canContinueHistoryCompactCheckpointForModel( + previous, + { providerType: 'openai-codex' }, + options.connectionId, + options.modelId, + ); const events = canContinuePrevious ? (input.newlyFoldedRuntimeEvents ?? []) : input.source.foldedRuntimeEvents; @@ -121,7 +124,7 @@ export function buildOpenAiCodexHistoryCompactor(options: BuildOpenAiCodexHistor if (streamError) throw streamError; const state = extractOpenAiCodexCompactionState( content, - options.connectionSlug, + options.connectionId, options.modelId, ); if (!state) throw new HistoryCompactSummarizerError('invalid_provider_state'); @@ -347,7 +350,7 @@ export function openAiCodexCompactionMessages( export function extractOpenAiCodexCompactionState( content: readonly unknown[] | undefined, - connectionSlug: string, + connectionId: string, modelId: string, ): HistoryCompactProviderState | undefined { const parts = (content ?? []).filter(isOpenAiCompactionPart); @@ -356,7 +359,7 @@ export function extractOpenAiCodexCompactionState( if (!nonEmpty(metadata.itemId) || !nonEmpty(metadata.encryptedContent)) return undefined; return { kind: 'openai_codex_remote_v2', - connectionSlug, + connectionId, modelId, itemId: metadata.itemId, encryptedContent: metadata.encryptedContent, From 7288c978e0dad2aaa8273508e652851c5ed89332 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 11:37:27 +0800 Subject: [PATCH 05/16] fix(runtime): enforce replay admission on recovery Generated-by: Codex --- .../src/__tests__/ai-sdk-backend.test.ts | 1 + .../src/__tests__/durable-turn-harness.ts | 2 + .../overflow-reactive-recovery.test.ts | 92 ++++++++++++++++++- .../__tests__/responses-wire-contract.test.ts | 61 ++++++------ packages/runtime/src/ai-sdk-backend.ts | 30 ++++-- packages/runtime/src/ai-sdk-compaction.ts | 14 ++- .../src/openai-codex-history-compactor.ts | 7 +- 7 files changed, 160 insertions(+), 47 deletions(-) diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index d91e9f611e..b7f8ec93ec 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -3996,6 +3996,7 @@ describe('AiSdkBackend model history', () => { const emitted: SessionEvent[] = []; for await (const event of backend.send({ + runId: 'run-1', turnId: 'turn-1', text: 'run both tools', context: [], diff --git a/packages/runtime/src/__tests__/durable-turn-harness.ts b/packages/runtime/src/__tests__/durable-turn-harness.ts index 62285e772b..0455e1b44d 100644 --- a/packages/runtime/src/__tests__/durable-turn-harness.ts +++ b/packages/runtime/src/__tests__/durable-turn-harness.ts @@ -66,6 +66,8 @@ export function createDurableTurnHarness(input: { loadTurnRuntimeEvents: async (turnId: string) => ledger.filter((event) => event.turnId === turnId), sendInput: (overrides: Partial = {}): BackendSendInput => ({ + invocationId, + runId, turnId: input.turnId, text: input.text, context: [], diff --git a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts index e95848a9a2..02a150ff41 100644 --- a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts +++ b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts @@ -27,7 +27,7 @@ import type { SessionHeader } from '@maka/core/session'; import type { SessionEvent } from '@maka/core/events'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { z } from 'zod'; -import type { ModelCallCommit } from '@maka/core/agent-run'; +import type { AgentRunHeader, ModelCallCommit } from '@maka/core/agent-run'; import { decodeModelCallAttempt, type ModelCallAttempt } from '@maka/core/model-call-attempt'; import { AiSdkBackend } from '../ai-sdk-backend.js'; import { @@ -44,6 +44,7 @@ import { type HistoryCompactCheckpoint, type HistoryCompactProviderState, } from '../history-compact-checkpoint.js'; +import { HistoryCompactSummarizerError } from '../history-compact-error.js'; import { createTestAiSdkBackend, testToolResultArchive, @@ -130,13 +131,17 @@ interface ReactiveFixtureOptions { midTurnEnabled?: boolean; withoutPriorTurns?: boolean; bigPriors?: boolean; + /** Leave same-route and cross-route signed thinking in a reduced pre-turn tail. */ + reasoningReplayTail?: boolean; /** Add one hydrated historical image that fits the proactive capacity estimate. */ imagePrior?: boolean; /** Put one image attachment on the durable current-turn user anchor. */ currentImage?: boolean; /** Make Read return a newly produced image during this turn. */ liveImageResult?: boolean; - summarize?: () => + summarize?: (input: { + source: { foldedRuntimeEvents: RuntimeEvent[] }; + }) => | Promise | string | HistoryCompactProviderState @@ -193,6 +198,7 @@ interface ReactiveFixture { summarizerCalls: () => number; anchor: RuntimeEvent; priorEvents: RuntimeEvent[]; + priorRunHeaders: AgentRunHeader[]; events: SessionEvent[]; llmCalls: ReactiveLlmCall[]; /** Canonical settlements, whole, when `canonicalAccounting` is on. */ @@ -457,7 +463,37 @@ function buildReactiveFixture(options: ReactiveFixtureOptions): ReactiveFixture 'model', `PRIOR_FACT answer ${'q'.repeat(priorChars)}`, ), + ...(options.reasoningReplayTail + ? [ + { + ...runtimeTextEvent('same-route-reasoning', 'turn-0', 'model', ''), + runId: 'same-route-prior-run', + invocationId: 'same-route-prior-run', + content: { + kind: 'thinking' as const, + text: 'SAME_ROUTE_PRIVATE_REASONING', + signature: 'same-route-signature', + }, + }, + { + ...runtimeTextEvent('prior-reasoning', 'turn-0', 'model', ''), + runId: 'prior-run', + invocationId: 'prior-run', + content: { + kind: 'thinking' as const, + text: 'CROSS_ROUTE_PRIVATE_REASONING', + signature: 'cross-route-signature', + }, + }, + ] + : []), ]; + const priorRunHeaders: AgentRunHeader[] = options.reasoningReplayTail + ? [ + priorRunHeader('same-route-prior-run', 'test-connection-id', 'mock-model-id'), + priorRunHeader('prior-run', 'source-connection-id', 'source-model-id'), + ] + : []; const anchor: RuntimeEvent = { ...runtimeTextEvent('anchor-1', 'turn-1', 'user', ANCHOR_TEXT), ...(options.currentImage @@ -523,7 +559,7 @@ function buildReactiveFixture(options: ReactiveFixtureOptions): ReactiveFixture encryptedContent: 'REACTIVE_ENCRYPTED_STATE', } satisfies HistoryCompactProviderState) : options.summarize - ? await options.summarize() + ? await options.summarize(input) : reactiveStructuredSummary(input.source.foldedRuntimeEvents); }, recordHistoryCompactCheckpoint: (checkpoint: HistoryCompactCheckpoint) => { @@ -659,6 +695,7 @@ function buildReactiveFixture(options: ReactiveFixtureOptions): ReactiveFixture summarizerCalls: () => counters.summarizerCalls, anchor, priorEvents, + priorRunHeaders, events, llmCalls, commits, @@ -680,6 +717,7 @@ async function runTurn( text: ANCHOR_TEXT, context: [], runtimeContext: [...fixture.priorEvents], + runtimeContextRunHeaders: fixture.priorRunHeaders, ...(pullSteering ? { pullSteering } : {}), })) { if (consumer === 'slow') { @@ -1117,6 +1155,36 @@ describe('reactive overflow recovery in the streaming backend', () => { assert.equal(successorPrompt.includes(RAW_SPAN_ONE), true); }); + test('step-0 overflow recovery gates reasoning on retry and durable reload', async () => { + let summarizeCalls = 0; + const fixture = buildReactiveFixture({ + script: ['overflow', 'tool', 'done'], + bigPriors: true, + reasoningReplayTail: true, + summarize: (input) => { + summarizeCalls += 1; + if (summarizeCalls <= 2) { + throw new HistoryCompactSummarizerError('input_too_large'); + } + return reactiveStructuredSummary(input.source.foldedRuntimeEvents); + }, + }); + await runTurn(fixture); + + assert.equal(fixture.model.doStreamCalls.length, 3); + assert.equal(fixture.summarizerCalls(), 3); + for (const call of fixture.model.doStreamCalls.slice(1)) { + const prompt = JSON.stringify(call.prompt); + assert.match(prompt, /REACTIVE_SUMMARY_SENTINEL/); + assert.match(prompt, new RegExp(ANCHOR_TEXT)); + assert.match(prompt, /SAME_ROUTE_PRIVATE_REASONING/); + assert.match(prompt, /same-route-signature/); + assert.doesNotMatch(prompt, /CROSS_ROUTE_PRIVATE_REASONING/); + assert.doesNotMatch(prompt, /cross-route-signature/); + } + assert.match(JSON.stringify(fixture.model.doStreamCalls[2]?.prompt), new RegExp(RAW_SPAN_ONE)); + }); + test('the resend seals the fold recovery made for it, and the request before it seals none', async () => { // Recovery is the other place the boundary moves between two dispatches of // one send, and the harder one: the fold happens after a request was @@ -1834,6 +1902,24 @@ function header(): SessionHeader { }; } +function priorRunHeader(runId: string, llmConnectionId: string, modelId: string): AgentRunHeader { + return { + runId, + sessionId: 'session-1', + turnId: 'turn-0', + status: 'completed', + backendKind: 'ai-sdk', + llmConnectionId, + llmConnectionSlug: 'anthropic-source', + modelId, + cwd: '/tmp/maka', + permissionMode: 'ask', + createdAt: 1, + updatedAt: 2, + completedAt: 2, + }; +} + function connection(): LlmConnection { return { slug: 'anthropic-main', diff --git a/packages/runtime/src/__tests__/responses-wire-contract.test.ts b/packages/runtime/src/__tests__/responses-wire-contract.test.ts index fbabfb3b7a..0b666e8ebf 100644 --- a/packages/runtime/src/__tests__/responses-wire-contract.test.ts +++ b/packages/runtime/src/__tests__/responses-wire-contract.test.ts @@ -621,38 +621,41 @@ describe('responses wire request body', () => { content, ...(refs ? { refs } : {}), }); - const messages = openAiCodexCompactionMessages([ - event('user', 'user', 'user', { kind: 'text', text: 'search' }), - event( - 'call', - 'model', - 'agent', - { - kind: 'function_call', + const messages = openAiCodexCompactionMessages( + [ + event('user', 'user', 'user', { kind: 'text', text: 'search' }), + event( + 'call', + 'model', + 'agent', + { + kind: 'function_call', + id: 'search-1', + name: 'WebSearch', + args: { query: 'latest Maka' }, + providerExecuted: true, + }, + { stepId: 'provider-step' }, + ), + event('result', 'tool', 'tool', { + kind: 'function_response', id: 'search-1', name: 'WebSearch', - args: { query: 'latest Maka' }, + result: { type: 'web_search_result', query: 'latest Maka' }, + providerOutput: { type: 'web_search_result', id: 'ws_123' }, providerExecuted: true, - }, - { stepId: 'provider-step' }, - ), - event('result', 'tool', 'tool', { - kind: 'function_response', - id: 'search-1', - name: 'WebSearch', - result: { type: 'web_search_result', query: 'latest Maka' }, - providerOutput: { type: 'web_search_result', id: 'ws_123' }, - providerExecuted: true, - isError: false, - }), - event( - 'text', - 'model', - 'agent', - { kind: 'text', text: 'Maka shipped.' }, - { providerEventId: 'provider-step' }, - ), - ]); + isError: false, + }), + event( + 'text', + 'model', + 'agent', + { kind: 'text', text: 'Maka shipped.' }, + { providerEventId: 'provider-step' }, + ), + ], + new Set(), + ); assert.deepEqual( messages.map((message) => ({ role: message.role, diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index ce04dbc046..b99340b5cc 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -1117,8 +1117,18 @@ export class AiSdkBackend implements AgentBackend { modelAdapter: this.modelAdapter, createProviderRequestTracker: (trackerInput) => this.createProviderRequestTracker(trackerInput), - materializeRuntimeReplayPlan: (plan, imageBudget, checkpoint) => - this.materializeRuntimeReplayPlan(plan, imageBudget, checkpoint), + materializeRuntimeReplayPlan: ( + plan, + imageBudget, + checkpoint, + providerReasoningReplayEventIds, + ) => + this.materializeRuntimeReplayPlan( + plan, + imageBudget, + checkpoint, + providerReasoningReplayEventIds, + ), canReplayProviderNative: (plan) => this.canReplayProviderNative(plan), }); if ( @@ -1917,6 +1927,13 @@ export class AiSdkBackend implements AgentBackend { replayPlan, scope.imageBudget, projectionCheckpoint, + compatibleProviderReasoningReplayEventIds( + replayEvents, + input.runtimeContextRunHeaders, + this.input.header.llmConnectionId, + this.input.modelId, + scope.runId, + ), ); return projectionCheckpoint ? currentTurnMessages @@ -3652,8 +3669,8 @@ export class AiSdkBackend implements AgentBackend { private async materializeRuntimeReplayPlan( plan: RuntimeEventModelReplayPlan, budget: ProviderImageBudget, - historyCompactCheckpoint?: HistoryCompactCheckpoint, - providerReasoningReplayEventIds?: ReadonlySet, + historyCompactCheckpoint: HistoryCompactCheckpoint | undefined, + providerReasoningReplayEventIds: ReadonlySet, ): Promise { type ToolCallItem = Extract; type ToolResultItem = Extract; @@ -3680,10 +3697,7 @@ export class AiSdkBackend implements AgentBackend { const replaySupport = this.modelAdapter.runtimeEventReplaySupport(); const reasoningReplay = (item: ThinkingItem): ReplayReasoning | undefined => { - if ( - providerReasoningReplayEventIds !== undefined && - !providerReasoningReplayEventIds.has(item.eventId) - ) { + if (!providerReasoningReplayEventIds.has(item.eventId)) { return undefined; } if (item.signature) { diff --git a/packages/runtime/src/ai-sdk-compaction.ts b/packages/runtime/src/ai-sdk-compaction.ts index da96f4b0c6..1f336082a3 100644 --- a/packages/runtime/src/ai-sdk-compaction.ts +++ b/packages/runtime/src/ai-sdk-compaction.ts @@ -91,6 +91,7 @@ import type { MakaTool } from './tool-runtime.js'; import { buildRuntimeEventModelReplayPlan, collectToolActivityTurnIds, + compatibleProviderReasoningReplayEventIds, type RuntimeEventModelReplayPlan, } from './model-history.js'; import { toolSchemaCharsForDiagnostics } from './request-shape.js'; @@ -176,7 +177,8 @@ export interface AiSdkCompactionDeps { materializeRuntimeReplayPlan: ( plan: RuntimeEventModelReplayPlan, imageBudget: ProviderImageBudget, - checkpoint?: HistoryCompactCheckpoint, + checkpoint: HistoryCompactCheckpoint | undefined, + providerReasoningReplayEventIds: ReadonlySet, ) => Promise; canReplayProviderNative: (plan: RuntimeEventModelReplayPlan) => boolean; } @@ -197,7 +199,8 @@ export class AiSdkCompaction { private readonly materializeRuntimeReplayPlan: ( plan: RuntimeEventModelReplayPlan, imageBudget: ProviderImageBudget, - checkpoint?: HistoryCompactCheckpoint, + checkpoint: HistoryCompactCheckpoint | undefined, + providerReasoningReplayEventIds: ReadonlySet, ) => Promise; private readonly canReplayProviderNative: (plan: RuntimeEventModelReplayPlan) => boolean; private historyCompactAbortController: AbortController | null = null; @@ -1074,6 +1077,13 @@ export class AiSdkCompaction { replayPlan, input.origin.imageBudget, plan.checkpoint, + compatibleProviderReasoningReplayEventIds( + plan.replacementEvents, + state.priorRunHeaders, + this.targetConnectionId, + this.input.modelId, + input.origin.runId, + ), ); // Apply the shape only when it actually shrinks the request versus the // reference payload (the incoming request for the proactive hook, the diff --git a/packages/runtime/src/openai-codex-history-compactor.ts b/packages/runtime/src/openai-codex-history-compactor.ts index 3287486ac6..a597c4212e 100644 --- a/packages/runtime/src/openai-codex-history-compactor.ts +++ b/packages/runtime/src/openai-codex-history-compactor.ts @@ -189,7 +189,7 @@ function hasAbortCause(error: unknown): boolean { */ export function openAiCodexCompactionMessages( events: readonly RuntimeEvent[], - providerReasoningReplayEventIds?: ReadonlySet, + providerReasoningReplayEventIds: ReadonlySet, ): ModelMessage[] { type ToolCall = Extract; type ToolResult = Extract; @@ -231,10 +231,7 @@ export function openAiCodexCompactionMessages( continue; } if (item.kind === 'thinking') { - if ( - providerReasoningReplayEventIds !== undefined && - !providerReasoningReplayEventIds.has(item.eventId) - ) { + if (!providerReasoningReplayEventIds.has(item.eventId)) { continue; } if (item.stepId) step(item.stepId).reasoning.push(item); From 38815d16ae354163aaf3ee97e4a79575e8fd8068 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 15:03:22 +0800 Subject: [PATCH 06/16] fix(runtime): bind continuation replay admission to target route Generated-by: Codex --- packages/core/src/backend-types.ts | 2 +- .../src/__tests__/continuation-replay.test.ts | 5 + .../__tests__/runtime-continuation.test.ts | 32 ++- .../src/__tests__/runtime-resume.test.ts | 12 + .../src/__tests__/session-manager.test.ts | 242 ++++++++++++++++++ packages/runtime/src/ai-sdk-backend.ts | 9 +- packages/runtime/src/continuation-replay.ts | 26 +- packages/runtime/src/model-history.ts | 14 + .../src/openai-codex-history-compactor.ts | 9 +- packages/runtime/src/runtime-kernel.ts | 48 +++- packages/runtime/src/runtime-resume.ts | 50 ++-- packages/runtime/src/session-manager.ts | 33 ++- 12 files changed, 437 insertions(+), 45 deletions(-) diff --git a/packages/core/src/backend-types.ts b/packages/core/src/backend-types.ts index a0d1e9b67e..8fbdc0bda5 100644 --- a/packages/core/src/backend-types.ts +++ b/packages/core/src/backend-types.ts @@ -94,7 +94,7 @@ export interface BackendSendInput { * provider-owned replay against the current model route. RuntimeEvents stay * the transcript authority; route provenance remains owned by AgentRun. */ - runtimeContextRunHeaders?: AgentRunHeader[]; + runtimeContextRunHeaders?: readonly AgentRunHeader[]; /** Continue from an already committed RuntimeEvent boundary without adding another user turn. */ continuation?: RuntimeContinuationMetadata; /** diff --git a/packages/runtime/src/__tests__/continuation-replay.test.ts b/packages/runtime/src/__tests__/continuation-replay.test.ts index 12238e83f3..51ef7c5286 100644 --- a/packages/runtime/src/__tests__/continuation-replay.test.ts +++ b/packages/runtime/src/__tests__/continuation-replay.test.ts @@ -326,6 +326,11 @@ describe('continuation replay segment', () => { const result = buildContinuationReplayPlan({ prefixes: [ancestor, source], providerProjectionVersion: PROVIDER_REPLAY_PROJECTION_VERSION, + admissionRoute: { + runHeaders: [], + targetConnectionId: undefined, + targetModelId: 'test-model', + }, }); assert.equal(result.kind, 'replayable'); diff --git a/packages/runtime/src/__tests__/runtime-continuation.test.ts b/packages/runtime/src/__tests__/runtime-continuation.test.ts index a9455be4a6..d2f7594e74 100644 --- a/packages/runtime/src/__tests__/runtime-continuation.test.ts +++ b/packages/runtime/src/__tests__/runtime-continuation.test.ts @@ -87,6 +87,7 @@ test('RuntimeContinuationPlanner reads the durable source boundary and allocates const plan = await planner.plan({ sessionId: 'session-1', sourceRunId: 'run-1', + admissionRoute: sameRouteAdmission(), currentCwd: '/workspace/repo', sourceWorkspaceIdentity: 'workspace-1', currentWorkspaceIdentity: 'workspace-1', @@ -140,6 +141,7 @@ test('RuntimeContinuationPlanner parks with a stable reason when the ledger cann const plan = await planner.plan({ sessionId: 'session-1', sourceRunId: 'run-1', + admissionRoute: sameRouteAdmission(), currentCwd: '/workspace/repo', sourceWorkspaceIdentity: 'workspace-1', currentWorkspaceIdentity: 'workspace-1', @@ -169,6 +171,7 @@ test('RuntimeContinuationPlanner derives terminal repair from durable run and ev const plan = await planner.plan({ sessionId: 'session-1', sourceRunId: 'run-1', + admissionRoute: sameRouteAdmission(), currentCwd: '/workspace/repo', sourceWorkspaceIdentity: 'workspace-1', currentWorkspaceIdentity: 'workspace-1', @@ -205,6 +208,7 @@ test('RuntimeContinuationPlanner parks when the terminal run header disagrees wi const plan = await planner.plan({ sessionId: 'session-1', sourceRunId: 'run-1', + admissionRoute: sameRouteAdmission(), currentCwd: '/workspace/repo', sourceWorkspaceIdentity: 'workspace-1', currentWorkspaceIdentity: 'workspace-1', @@ -248,6 +252,7 @@ test('RuntimeContinuationPlanner rejects immutable output after the source termi const plan = await planner.plan({ sessionId: 'session-1', sourceRunId: 'run-1', + admissionRoute: sameRouteAdmission(), currentCwd: '/workspace/repo', sourceWorkspaceIdentity: 'workspace-1', currentWorkspaceIdentity: 'workspace-1', @@ -293,6 +298,7 @@ test('RuntimeContinuationPlanner uses canonical provider items for composite hea const plan = await planner.plan({ sessionId: 'session-1', sourceRunId: 'run-1', + admissionRoute: sameRouteAdmission(), currentCwd: '/workspace/repo', sourceWorkspaceIdentity: 'workspace-1', currentWorkspaceIdentity: 'workspace-1', @@ -332,6 +338,7 @@ test('RuntimeContinuationPlanner rejects a ledger returned for another source ru const plan = await planner.plan({ sessionId: 'session-1', sourceRunId: 'run-1', + admissionRoute: sameRouteAdmission(), currentCwd: '/workspace/repo', sourceWorkspaceIdentity: 'workspace-1', currentWorkspaceIdentity: 'workspace-1', @@ -375,6 +382,7 @@ test('RuntimeContinuationPlanner fails a cyclic continuation lineage closed', as const plan = await planner.plan({ sessionId: 'session-1', sourceRunId: 'run-1', + admissionRoute: sameRouteAdmission(), currentCwd: '/workspace/repo', sourceWorkspaceIdentity: 'workspace-1', currentWorkspaceIdentity: 'workspace-1', @@ -412,6 +420,7 @@ test('RuntimeContinuationPlanner parks when a continuation ancestor is unavailab const plan = await planner.plan({ sessionId: 'session-1', sourceRunId: 'run-2', + admissionRoute: sameRouteAdmission(), currentCwd: '/workspace/repo', sourceWorkspaceIdentity: 'workspace-1', currentWorkspaceIdentity: 'workspace-1', @@ -458,6 +467,7 @@ test('RuntimeContinuationPlanner caps continuation lineage at 64 segments', asyn const plan = await planner.plan({ sessionId: 'session-1', sourceRunId: 'run-1', + admissionRoute: sameRouteAdmission(), currentCwd: '/workspace/repo', sourceWorkspaceIdentity: 'workspace-1', currentWorkspaceIdentity: 'workspace-1', @@ -529,6 +539,7 @@ test('RuntimeContinuationPlanner verifies a v2 lineage edge prefix digest', asyn const plan = await planner.plan({ sessionId: 'session-1', sourceRunId: 'run-2', + admissionRoute: sameRouteAdmission(), currentCwd: '/workspace/repo', sourceWorkspaceIdentity: 'workspace-1', currentWorkspaceIdentity: 'workspace-1', @@ -606,6 +617,7 @@ test('RuntimeContinuationPlanner binds every v2 lineage edge to its continuation const plan = await planner.plan({ sessionId: 'session-1', sourceRunId: 'run-2', + admissionRoute: sameRouteAdmission(), currentCwd: '/workspace/repo', sourceWorkspaceIdentity: 'workspace-1', currentWorkspaceIdentity: 'workspace-1', @@ -622,6 +634,7 @@ test('RuntimeContinuationPlanner rejects downgrading a canonical v2 start to leg const ancestorReplay = buildContinuationReplayPlan({ prefixes: [ancestor], providerProjectionVersion: PROVIDER_REPLAY_PROJECTION_VERSION, + admissionRoute: sameRouteAdmission(), }); assert.equal(ancestorReplay.kind, 'replayable'); if (ancestorReplay.kind !== 'replayable') return; @@ -684,6 +697,7 @@ test('RuntimeContinuationPlanner rejects downgrading a canonical v2 start to leg const plan = await planner.plan({ sessionId: 'session-1', sourceRunId: sourceIdentity.runId, + admissionRoute: sameRouteAdmission(), currentCwd: '/workspace/repo', sourceWorkspaceIdentity: 'workspace-1', currentWorkspaceIdentity: 'workspace-1', @@ -695,11 +709,12 @@ test('RuntimeContinuationPlanner rejects downgrading a canonical v2 start to leg assert.deepEqual(plan.rejectionReasons, ['runtime_lineage_start_mismatch']); }); -test('RuntimeContinuationPlanner authenticates every v2 edge provider replay digest', async () => { +test('RuntimeContinuationPlanner requires a durable target before authenticating edge replay', async () => { const ancestor = prefixForIdentity('invocation-1', 'run-1', 'turn-1'); const ancestorReplay = buildContinuationReplayPlan({ prefixes: [ancestor], providerProjectionVersion: PROVIDER_REPLAY_PROJECTION_VERSION, + admissionRoute: sameRouteAdmission(), }); assert.equal(ancestorReplay.kind, 'replayable'); if (ancestorReplay.kind !== 'replayable') return; @@ -767,6 +782,7 @@ test('RuntimeContinuationPlanner authenticates every v2 edge provider replay dig const plan = await planner.plan({ sessionId: 'session-1', sourceRunId: sourceIdentity.runId, + admissionRoute: sameRouteAdmission(), currentCwd: '/workspace/repo', sourceWorkspaceIdentity: 'workspace-1', currentWorkspaceIdentity: 'workspace-1', @@ -775,7 +791,7 @@ test('RuntimeContinuationPlanner authenticates every v2 edge provider replay dig }); assert.equal(plan.disposition, 'park'); - assert.deepEqual(plan.rejectionReasons, ['runtime_lineage_replay_mismatch']); + assert.deepEqual(plan.rejectionReasons, ['continuation_authority_unavailable']); }); test('RuntimeContinuationPlanner rejects a v2 lineage edge whose durable claim is missing', async () => { @@ -783,6 +799,7 @@ test('RuntimeContinuationPlanner rejects a v2 lineage edge whose durable claim i const ancestorReplay = buildContinuationReplayPlan({ prefixes: [ancestor], providerProjectionVersion: PROVIDER_REPLAY_PROJECTION_VERSION, + admissionRoute: sameRouteAdmission(), }); assert.equal(ancestorReplay.kind, 'replayable'); if (ancestorReplay.kind !== 'replayable') return; @@ -850,6 +867,7 @@ test('RuntimeContinuationPlanner rejects a v2 lineage edge whose durable claim i const plan = await planner.plan({ sessionId: 'session-1', sourceRunId: sourceIdentity.runId, + admissionRoute: sameRouteAdmission(), currentCwd: '/workspace/repo', sourceWorkspaceIdentity: 'workspace-1', currentWorkspaceIdentity: 'workspace-1', @@ -861,6 +879,16 @@ test('RuntimeContinuationPlanner rejects a v2 lineage edge whose durable claim i assert.deepEqual(plan.rejectionReasons, ['runtime_lineage_claim_mismatch']); }); +function sameRouteAdmission() { + return { + runHeaders: ['run-1', 'run-2', 'run-3'].map((runId) => + runHeader(runId, { llmConnectionId: 'connection-1' }), + ), + targetConnectionId: 'connection-1', + targetModelId: 'test-model', + }; +} + function runHeader(runId: string, overrides: Partial = {}): AgentRunHeader { const ordinal = runId.match(/(\d+)$/)?.[1] ?? '1'; const status = overrides.status ?? 'failed'; diff --git a/packages/runtime/src/__tests__/runtime-resume.test.ts b/packages/runtime/src/__tests__/runtime-resume.test.ts index 21207ab0c9..a311e6624a 100644 --- a/packages/runtime/src/__tests__/runtime-resume.test.ts +++ b/packages/runtime/src/__tests__/runtime-resume.test.ts @@ -316,6 +316,7 @@ describe('runtime resume phase 1 safe-boundary continuation', () => { const plan = await planner.plan({ sessionId: 'session-1', sourceRunId: 'run-2', + admissionRoute: sameRouteAdmission(), currentCwd: '/workspace/repo', sourceWorkspaceIdentity: 'workspace-1', currentWorkspaceIdentity: 'workspace-1', @@ -380,6 +381,7 @@ describe('runtime resume phase 1 safe-boundary continuation', () => { const replay = buildContinuationReplayPlan({ prefixes: [immutablePrefix(events)], providerProjectionVersion: PROVIDER_REPLAY_PROJECTION_VERSION, + admissionRoute: sameRouteAdmission(), }); assert.equal(replay.kind, 'replayable'); if (replay.kind !== 'replayable') return; @@ -727,6 +729,16 @@ function safeBoundaryFacts() { }; } +function sameRouteAdmission() { + return { + runHeaders: ['run-1', 'run-2', 'run-3'].map((runId) => + runHeader(runId, { llmConnectionId: 'connection-1' }), + ), + targetConnectionId: 'connection-1', + targetModelId: 'test-model', + }; +} + function runHeader(runId: string, overrides: Partial = {}): AgentRunHeader { const ordinal = runId.match(/(\d+)$/)?.[1] ?? '1'; const status = overrides.status ?? 'failed'; diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index fa7d22e808..32e810432f 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -137,6 +137,8 @@ import { claimAgentGraphRunnableIntent, fingerprintAgentGraphRunnableIntent, } from '../stream-graph-admission.js'; +import { digestProviderReplay } from '../continuation-replay.js'; +import { buildRuntimeEventModelReplayPlan } from '../model-history.js'; import type { AgentGraphRunnableIntent } from '../stream-graph-readiness.js'; test('sendMessage rejects removed Automation as a live trigger', async () => { @@ -5388,6 +5390,246 @@ describe('SessionManager permission mode updates', () => { ); }); + test('authenticates the same cross-route continuation projection that reaches the provider', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const backends = new BackendRegistry(); + let providerRequest: unknown; + const model = new MockLanguageModelV4({ + doStream: async (request) => { + providerRequest = request; + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'continued' }, + { type: 'text-end', id: 'text-1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }, + ] as LanguageModelV4StreamPart[], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + backends.register('ai-sdk', (ctx) => + createTestAiSdkBackend({ + sessionId: ctx.sessionId, + header: ctx.header, + appendMessage: ctx.appendMessage ?? (async () => {}), + connection: { + slug: ctx.header.llmConnectionSlug, + providerType: 'anthropic', + defaultModel: ctx.header.model, + }, + apiKey: 'sk-test', + modelId: ctx.header.model, + modelFactory: () => model, + tools: [ + { + name: 'Read', + description: 'Read a file', + parameters: z.object({ path: z.string() }), + impl: async () => ({ ok: true }), + }, + ], + ...(ctx.loadTurnRuntimeEvents ? { loadTurnRuntimeEvents: ctx.loadTurnRuntimeEvents } : {}), + newId: (() => { + let id = 0; + return () => `cross-route-backend-${++id}`; + })(), + now: nextNow(1), + }), + ); + const manager = new SessionManager({ + store, + runStore, + runtimeEventStore: runStore, + toolBoundaryProtocol: 't1_after_preflight_v1', + backends, + childTools: [testTool('Read')], + inspectContinuationSafety: async () => ({ + workspaceIdentity: 'workspace-1', + backgroundOperationsSettled: true, + availableToolNames: ['Read'], + }), + newId: nextId(), + now: nextNow(6_575), + }); + const session = await manager.createSession( + makeInput({ + llmConnectionId: 'connection-a', + llmConnectionSlug: 'anthropic-a', + model: 'claude-a', + permissionMode: 'bypass', + }), + ); + const sourceRunId = 'source-run-cross-route'; + const sourceInvocationId = 'source-invocation-cross-route'; + const sourceTurnId = 'source-turn-cross-route'; + await runStore.createRun({ + runId: sourceRunId, + invocationId: sourceInvocationId, + sessionId: session.id, + turnId: sourceTurnId, + status: 'failed', + failureClass: 'runtime_interrupted', + backendKind: 'ai-sdk', + llmConnectionId: 'connection-a', + llmConnectionSlug: 'anthropic-a', + modelId: 'claude-a', + cwd: '/tmp/cwd', + workspaceIdentity: 'workspace-1', + permissionMode: 'bypass', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'code_mode', + createdAt: 1, + updatedAt: 5, + completedAt: 5, + }); + const sourceEvents: RuntimeEvent[] = [ + { + id: 'cross-route-user', + sessionId: session.id, + invocationId: sourceInvocationId, + runId: sourceRunId, + turnId: sourceTurnId, + ts: 1, + partial: false, + author: 'user', + role: 'user', + content: { kind: 'text', text: 'continue across routes' }, + }, + { + id: 'cross-route-thinking', + sessionId: session.id, + invocationId: sourceInvocationId, + runId: sourceRunId, + turnId: sourceTurnId, + ts: 2, + partial: false, + author: 'agent', + role: 'model', + content: { + kind: 'thinking', + text: 'source-only reasoning', + signature: 'source-only-signature', + }, + refs: { stepId: 'cross-route-step' }, + }, + { + id: 'cross-route-tool-call', + sessionId: session.id, + invocationId: sourceInvocationId, + runId: sourceRunId, + turnId: sourceTurnId, + ts: 3, + partial: false, + author: 'agent', + role: 'model', + content: { + kind: 'function_call', + id: 'cross-route-read', + name: 'Read', + args: { path: 'package.json' }, + }, + refs: { stepId: 'cross-route-step' }, + }, + { + id: 'cross-route-tool-result', + sessionId: session.id, + invocationId: sourceInvocationId, + runId: sourceRunId, + turnId: sourceTurnId, + ts: 4, + partial: false, + author: 'tool', + role: 'tool', + content: { + kind: 'function_response', + id: 'cross-route-read', + name: 'Read', + result: 'package contents', + }, + }, + { + id: 'cross-route-terminal', + sessionId: session.id, + invocationId: sourceInvocationId, + runId: sourceRunId, + turnId: sourceTurnId, + ts: 5, + partial: false, + author: 'system', + role: 'system', + status: 'failed', + actions: { endInvocation: true, stateDelta: { failureClass: 'runtime_interrupted' } }, + }, + ]; + for (const event of sourceEvents) { + await runStore.appendRuntimeEvent(session.id, sourceRunId, event); + } + const planInput = { + sourceRunId, + currentCwd: '/tmp/cwd', + sourceWorkspaceIdentity: 'workspace-1', + currentWorkspaceIdentity: 'workspace-1', + backgroundOperationsSettled: true as const, + availableToolNames: ['Read'], + }; + const stalePlan = await manager.planSafeBoundaryContinuation(session.id, planInput); + expect(stalePlan.disposition).toBe('continue'); + if (!stalePlan.continuation) throw new Error('expected stale continuation'); + const staleContinuation = stalePlan.continuation; + await store.updateHeader(session.id, { + llmConnectionId: 'connection-b', + llmConnectionSlug: 'anthropic-b', + model: 'claude-b', + }); + await assert.rejects( + () => collectSessionEvents(manager.resumeSafeBoundaryContinuation(staleContinuation)), + /replay changed after planning/, + ); + expect(providerRequest).toBe(undefined); + + const plan = await manager.planSafeBoundaryContinuation(session.id, planInput); + expect(plan.disposition).toBe('continue'); + if (!plan.continuation) throw new Error('expected continuation'); + + const sessionEvents = await collectSessionEvents( + manager.resumeSafeBoundaryContinuation(plan.continuation), + ); + + assert.ok(providerRequest, JSON.stringify(sessionEvents)); + const promptJson = JSON.stringify(providerRequest); + expect(promptJson).not.toContain('source-only reasoning'); + expect(promptJson).not.toContain('source-only-signature'); + assert.match(promptJson, /continue across routes/, promptJson); + expect(promptJson).toContain('cross-route-read'); + expect(promptJson).toContain('package contents'); + const rawReplay = buildRuntimeEventModelReplayPlan(plan.continuation.runtimeContext); + const admittedReplayItems = rawReplay.items.filter((item) => item.kind !== 'thinking'); + expect(plan.continuation.providerReplayDigest).toBe( + digestProviderReplay(1, admittedReplayItems), + ); + const continuationEvents = await runStore.readRuntimeEvents( + session.id, + plan.continuation.runId, + ); + expect(continuationEvents[0]?.actions?.continuationStart?.providerReplayDigest).toBe( + plan.continuation.providerReplayDigest, + ); + }); + test('strict backend refresh settles after an active continuation finalizes', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index b99340b5cc..279ea2d318 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -207,6 +207,7 @@ import { import { computeCost } from './telemetry/cost.js'; import { getBuiltinPricing } from './telemetry/builtin-pricing.js'; import { + admitProviderReasoningReplayItems, buildRuntimeEventModelReplayPlan, buildSteeringEnvelope, collectToolActivityTurnIds, @@ -3697,9 +3698,6 @@ export class AiSdkBackend implements AgentBackend { const replaySupport = this.modelAdapter.runtimeEventReplaySupport(); const reasoningReplay = (item: ThinkingItem): ReplayReasoning | undefined => { - if (!providerReasoningReplayEventIds.has(item.eventId)) { - return undefined; - } if (item.signature) { return replaySupport.signedThinking ? { @@ -3985,7 +3983,10 @@ export class AiSdkBackend implements AgentBackend { } }; - for (const item of plan.items) { + for (const item of admitProviderReasoningReplayItems( + plan.items, + providerReasoningReplayEventIds, + )) { switch (item.kind) { case 'tool_call': if (item.toolName !== 'apply_patch') { diff --git a/packages/runtime/src/continuation-replay.ts b/packages/runtime/src/continuation-replay.ts index bbfac86c2e..bfa6b805a2 100644 --- a/packages/runtime/src/continuation-replay.ts +++ b/packages/runtime/src/continuation-replay.ts @@ -18,6 +18,7 @@ */ import { createHash } from 'node:crypto'; +import type { AgentRunHeader } from '@maka/core/agent-run'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { stableJsonStringify } from '@maka/core/tool-args-identity'; import { @@ -30,7 +31,9 @@ import { } from '@maka/core/runtime-boundary'; import type { RuntimeEventModelReplayItem, RuntimeEventReplayDiagnostic } from './model-history.js'; import { + admitProviderReasoningReplayItems, buildRuntimeEventModelReplayPlan, + compatibleProviderReasoningReplayEventIds, PROVIDER_REPLAY_PROJECTION_VERSION, } from './model-history.js'; import { resolveRuntimeRecovery } from './recovery-resolver.js'; @@ -44,7 +47,6 @@ export interface ContinuationReplaySegmentV1 { export interface ContinuationReplaySegmentPlanV1 { protocol: 'continuation_replay_segment_plan_v1'; providerProjectionVersion: typeof PROVIDER_REPLAY_PROJECTION_VERSION; - providerReplayDigest: RuntimeBoundaryDigest; segment: ContinuationReplaySegmentV1; providerItems: readonly RuntimeEventModelReplayItem[]; } @@ -82,9 +84,16 @@ export type ContinuationReplayPlanResult = diagnostics: readonly RuntimeEventReplayDiagnostic[]; }; +export interface ContinuationReplayAdmissionRoute { + runHeaders: readonly AgentRunHeader[]; + targetConnectionId: string | undefined; + targetModelId: string; +} + export function buildContinuationReplayPlan(input: { prefixes: readonly [ImmutableRuntimePrefixV1, ...ImmutableRuntimePrefixV1[]]; providerProjectionVersion: typeof PROVIDER_REPLAY_PROJECTION_VERSION; + admissionRoute: ContinuationReplayAdmissionRoute; }): ContinuationReplayPlanResult { const segmentPlans: ContinuationReplaySegmentPlanV1[] = []; for (const [segmentIndex, prefix] of input.prefixes.entries()) { @@ -102,7 +111,17 @@ export function buildContinuationReplayPlan(input: { ...RuntimePrefixSegmentV1[], ]; const segments = segmentPlans.map((plan) => plan.segment); - const providerItems = segmentPlans.flatMap((plan) => plan.providerItems); + const runtimeContext = segments.flatMap((segment) => segment.replayRuntimeEvents); + const providerReasoningReplayEventIds = compatibleProviderReasoningReplayEventIds( + runtimeContext, + input.admissionRoute.runHeaders, + input.admissionRoute.targetConnectionId, + input.admissionRoute.targetModelId, + ); + const providerItems = admitProviderReasoningReplayItems( + segmentPlans.flatMap((plan) => plan.providerItems), + providerReasoningReplayEventIds, + ); return { kind: 'replayable', plan: { @@ -111,7 +130,7 @@ export function buildContinuationReplayPlan(input: { boundary: createRuntimeBoundaryCursor(boundaries), providerReplayDigest: digestProviderReplay(input.providerProjectionVersion, providerItems), segments, - runtimeContext: segments.flatMap((segment) => segment.replayRuntimeEvents), + runtimeContext, providerItems, }, }; @@ -219,7 +238,6 @@ export function buildContinuationReplaySegment(input: { plan: { protocol: 'continuation_replay_segment_plan_v1', providerProjectionVersion: input.providerProjectionVersion, - providerReplayDigest: digestProviderReplay(input.providerProjectionVersion, providerItems), segment: { boundary: runtimePrefixSegment(input.prefix), replayRuntimeEvents, diff --git a/packages/runtime/src/model-history.ts b/packages/runtime/src/model-history.ts index 7147b43646..c0a708fb0e 100644 --- a/packages/runtime/src/model-history.ts +++ b/packages/runtime/src/model-history.ts @@ -104,6 +104,20 @@ export function compatibleProviderReasoningReplayEventIds( ); } +/** + * Apply provider-reasoning admission without disturbing portable transcript + * or tool evidence. Durable replay identities and wire materializers share + * this item projection. + */ +export function admitProviderReasoningReplayItems( + items: readonly RuntimeEventModelReplayItem[], + providerReasoningReplayEventIds: ReadonlySet, +): RuntimeEventModelReplayItem[] { + return items.filter( + (item) => item.kind !== 'thinking' || providerReasoningReplayEventIds.has(item.eventId), + ); +} + // ============================================================================ // Effective model-history sizing // ============================================================================ diff --git a/packages/runtime/src/openai-codex-history-compactor.ts b/packages/runtime/src/openai-codex-history-compactor.ts index a597c4212e..b100ea2417 100644 --- a/packages/runtime/src/openai-codex-history-compactor.ts +++ b/packages/runtime/src/openai-codex-history-compactor.ts @@ -32,6 +32,7 @@ import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { ModelMessage } from './model-protocol.js'; import { fitHistoryCompactMessages } from './history-compact-input-fit.js'; import { + admitProviderReasoningReplayItems, buildRuntimeEventModelReplayPlan, compatibleProviderReasoningReplayEventIds, type RuntimeEventModelReplayItem, @@ -206,7 +207,10 @@ export function openAiCodexCompactionMessages( | { kind: 'text'; item: Text } | { kind: 'thinking'; item: Thinking }; - const items = buildRuntimeEventModelReplayPlan(events).items; + const items = admitProviderReasoningReplayItems( + buildRuntimeEventModelReplayPlan(events).items, + providerReasoningReplayEventIds, + ); const results = new Map(); for (const item of items) { if (item.kind === 'tool_result') results.set(item.toolCallId, item); @@ -231,9 +235,6 @@ export function openAiCodexCompactionMessages( continue; } if (item.kind === 'thinking') { - if (!providerReasoningReplayEventIds.has(item.eventId)) { - continue; - } if (item.stepId) step(item.stepId).reasoning.push(item); else timeline.push({ kind: 'thinking', item }); continue; diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index ae54c30e18..ebcbfa97e1 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -114,9 +114,15 @@ import { type RuntimeContinuation, type RuntimeContinuationSafetyObservation, } from './runtime-resume.js'; -import { buildContinuationReplayPlan, digestProviderReplay } from './continuation-replay.js'; import { + buildContinuationReplayPlan, + digestProviderReplay, + type ContinuationReplayAdmissionRoute, +} from './continuation-replay.js'; +import { + admitProviderReasoningReplayItems, buildRuntimeEventModelReplayPlan, + compatibleProviderReasoningReplayEventIds, PROVIDER_REPLAY_PROJECTION_VERSION, } from './model-history.js'; import { @@ -732,11 +738,20 @@ export class RuntimeKernel implements RuntimeKernelLike { } const header = await this.deps.store.readHeader(continuation.sessionId); - const sourceRun = await this.deps.runStore.readRun( - continuation.sessionId, - continuation.sourceRunId, + const [sourceRun, sessionRuns] = await Promise.all([ + this.deps.runStore.readRun(continuation.sessionId, continuation.sourceRunId), + this.deps.runStore.listSessionRuns(continuation.sessionId), + ]); + const admissionRoute: ContinuationReplayAdmissionRoute = { + runHeaders: sessionRuns, + targetConnectionId: header.llmConnectionId, + targetModelId: header.model, + }; + const sourceEvents = await revalidateContinuationBoundary( + continuationAuthority, + continuation, + admissionRoute, ); - const sourceEvents = await revalidateContinuationBoundary(continuationAuthority, continuation); assertContinuationSourceUnchanged(continuation, sourceRun, sourceEvents); await this.revalidateContinuationSafety(continuation); @@ -767,7 +782,6 @@ export class RuntimeKernel implements RuntimeKernelLike { } await this.deps.continuationFailpoint?.('after_continuation_claim_committed'); - const sessionRuns = await this.deps.runStore.listSessionRuns(continuation.sessionId); const existingClaim = sessionRuns.find( (runHeader) => runHeader.continuationSource?.sourceRunId === continuation.sourceRunId && @@ -880,7 +894,7 @@ export class RuntimeKernel implements RuntimeKernelLike { this.attachExecutionClaim(execution, run); yield* this.runAgentContinuation( continuation, - sessionRuns, + admissionRoute, run, execution, { @@ -1249,7 +1263,7 @@ export class RuntimeKernel implements RuntimeKernelLike { private async *runAgentContinuation( continuation: RuntimeContinuation, - runtimeContextRunHeaders: AgentRunHeader[], + admissionRoute: ContinuationReplayAdmissionRoute, run: AgentRun, execution: PendingExecutionClaim, messageOwner?: RuntimeMessageRunIdentity, @@ -1294,6 +1308,7 @@ export class RuntimeKernel implements RuntimeKernelLike { try { continuationMetadata = consumeAdmittedRuntimeContinuation({ continuation, + admissionRoute, startAdmission: 'continuationStartAdmission' in begin ? begin.continuationStartAdmission @@ -1332,7 +1347,7 @@ export class RuntimeKernel implements RuntimeKernelLike { text: '', context: [], runtimeContext: continuation.runtimeContext, - runtimeContextRunHeaders, + runtimeContextRunHeaders: admissionRoute.runHeaders, continuation: continuationMetadata, }, onSessionEvent: async (sessionEvent, runtimeEvent) => { @@ -2684,6 +2699,7 @@ function requireRuntimeContinuationAuthority( async function revalidateContinuationBoundary( store: RuntimeContinuationAuthorityStore, continuation: RuntimeContinuation, + admissionRoute: ContinuationReplayAdmissionRoute, ): Promise { if ( !continuation.boundary || @@ -2721,6 +2737,7 @@ async function revalidateContinuationBoundary( const replay = buildContinuationReplayPlan({ prefixes: prefixes as [ImmutableRuntimePrefixV1, ...ImmutableRuntimePrefixV1[]], providerProjectionVersion: continuation.providerProjectionVersion, + admissionRoute, }); if ( replay.kind !== 'replayable' || @@ -2842,6 +2859,7 @@ function continuationTargetRunHeaderForExecution(input: { function consumeAdmittedRuntimeContinuation(input: { continuation: RuntimeContinuation; + admissionRoute: ContinuationReplayAdmissionRoute; startAdmission: RuntimeContinuationStartAdmissionProof; toolBoundaryProtocol?: ToolBoundaryProtocol; }): RuntimeContinuationMetadata { @@ -2888,8 +2906,18 @@ function consumeAdmittedRuntimeContinuation(input: { throw new Error('Runtime continuation durable admission boundary is inconsistent'); } const replay = buildRuntimeEventModelReplayPlan(continuation.runtimeContext); + const providerReasoningReplayEventIds = compatibleProviderReasoningReplayEventIds( + continuation.runtimeContext, + input.admissionRoute.runHeaders, + input.admissionRoute.targetConnectionId, + input.admissionRoute.targetModelId, + ); + const admittedItems = admitProviderReasoningReplayItems( + replay.items, + providerReasoningReplayEventIds, + ); if ( - digestProviderReplay(continuation.providerProjectionVersion, replay.items) !== + digestProviderReplay(continuation.providerProjectionVersion, admittedItems) !== continuation.providerReplayDigest ) { throw new Error('Runtime continuation provider replay identity changed after admission'); diff --git a/packages/runtime/src/runtime-resume.ts b/packages/runtime/src/runtime-resume.ts index 6e861da307..6da22e1ae7 100644 --- a/packages/runtime/src/runtime-resume.ts +++ b/packages/runtime/src/runtime-resume.ts @@ -37,6 +37,7 @@ import type { ContinuationClaimStateV1 } from '@maka/core/runtime-event-store'; import { isDeepStrictEqual } from 'node:util'; import { buildContinuationReplayPlan, + type ContinuationReplayAdmissionRoute, type ContinuationReplayPlanV1, } from './continuation-replay.js'; import { @@ -358,6 +359,7 @@ export interface SafeBoundaryContinuationPlan { export interface RuntimeContinuationPlannerInput { sessionId: string; sourceRunId: string; + admissionRoute: ContinuationReplayAdmissionRoute; currentCwd: string; sourceWorkspaceIdentity: string; currentWorkspaceIdentity: string; @@ -398,7 +400,12 @@ export class RuntimeContinuationPlanner { let prefixes: [ImmutableRuntimePrefixV1, ...ImmutableRuntimePrefixV1[]]; try { - prefixes = await this.readLineagePrefixes(input.sessionId, input.sourceRunId, sourceRun); + prefixes = await this.readLineagePrefixes( + input.sessionId, + input.sourceRunId, + sourceRun, + input.admissionRoute.runHeaders, + ); } catch (error) { if (error instanceof RuntimeLineageError) { return parkedPlan(error.code, error.message); @@ -422,6 +429,7 @@ export class RuntimeContinuationPlanner { const replay = buildContinuationReplayPlan({ prefixes, providerProjectionVersion: PROVIDER_REPLAY_PROJECTION_VERSION, + admissionRoute: input.admissionRoute, }); if (replay.kind === 'blocked') { const reason = @@ -614,6 +622,7 @@ export class RuntimeContinuationPlanner { sessionId: string, sourceRunId: string, sourceRun: Awaited>, + runHeaders: readonly AgentRunHeader[], ): Promise<[ImmutableRuntimePrefixV1, ...ImmutableRuntimePrefixV1[]]> { const immediate = await this.deps.readImmutableRuntimePrefix({ sessionId, @@ -744,23 +753,6 @@ export class RuntimeContinuationPlanner { `continuation lineage edge for ${edge.childRunId} is incomplete`, ); } - const edgeReplay = buildContinuationReplayPlan({ - prefixes: segments.slice(0, childIndex) as [ - ImmutableRuntimePrefixV1, - ...ImmutableRuntimePrefixV1[], - ], - providerProjectionVersion: edge.providerProjectionVersion, - }); - if ( - edgeReplay.kind !== 'replayable' || - edgeReplay.plan.boundary.manifestDigest !== edge.boundaryDigest || - edgeReplay.plan.providerReplayDigest !== edge.providerReplayDigest - ) { - throw new RuntimeLineageError( - 'runtime_lineage_replay_mismatch', - `continuation provider replay changed before ${edge.childRunId}`, - ); - } if (!this.deps.readContinuationClaimStateByBoundary) { throw new RuntimeLineageError( 'continuation_authority_unavailable', @@ -790,6 +782,28 @@ export class RuntimeContinuationPlanner { `durable continuation claim does not authenticate ${edge.childRunId}`, ); } + const edgeReplay = buildContinuationReplayPlan({ + prefixes: segments.slice(0, childIndex) as [ + ImmutableRuntimePrefixV1, + ...ImmutableRuntimePrefixV1[], + ], + providerProjectionVersion: edge.providerProjectionVersion, + admissionRoute: { + runHeaders, + targetConnectionId: state.claim.targetRunHeader.llmConnectionId, + targetModelId: state.claim.targetRunHeader.modelId, + }, + }); + if ( + edgeReplay.kind !== 'replayable' || + edgeReplay.plan.boundary.manifestDigest !== edge.boundaryDigest || + edgeReplay.plan.providerReplayDigest !== edge.providerReplayDigest + ) { + throw new RuntimeLineageError( + 'runtime_lineage_replay_mismatch', + `continuation provider replay changed before ${edge.childRunId}`, + ); + } } return segments as [ImmutableRuntimePrefixV1, ...ImmutableRuntimePrefixV1[]]; } diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index cf2a233f80..bed7890ad5 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -264,7 +264,10 @@ export type CompactSessionInput = }; }; -export type PlanSafeBoundaryContinuationInput = Omit; +export type PlanSafeBoundaryContinuationInput = Omit< + RuntimeContinuationPlannerInput, + 'sessionId' | 'admissionRoute' +>; export interface PlanAuthoritativeSafeBoundaryContinuationInput { sourceRunId: string; @@ -1977,6 +1980,32 @@ export class SessionManager { sessionId: string, input: PlanSafeBoundaryContinuationInput, ): Promise { + let admissionRoute: RuntimeContinuationPlannerInput['admissionRoute']; + try { + if (!this.deps.runStore) throw new Error('AgentRunStore is not configured'); + const [header, runHeaders] = await Promise.all([ + this.deps.store.readHeader(sessionId), + this.deps.runStore.listSessionRuns(sessionId), + ]); + admissionRoute = { + runHeaders, + targetConnectionId: header.llmConnectionId, + targetModelId: header.model, + }; + } catch { + const plan: SafeBoundaryContinuationPlan = { + disposition: 'park', + rejectionReasons: ['continuation_authority_unavailable'], + diagnostics: [ + { + code: 'continuation_authority_unavailable', + message: 'provider replay admission authority is unavailable', + }, + ], + }; + this.recordContinuationPlan(sessionId, input.sourceRunId, plan); + return plan; + } const planner = new RuntimeContinuationPlanner({ readSourceRun: async (targetSessionId, runId) => { if (!this.deps.runStore) throw new Error('AgentRunStore is not configured'); @@ -2008,7 +2037,7 @@ export class SessionManager { }, newId: this.deps.newId, }); - const plan = await planner.plan({ sessionId, ...input }); + const plan = await planner.plan({ sessionId, admissionRoute, ...input }); this.recordContinuationPlan(sessionId, input.sourceRunId, plan); return plan; } From a0975ae561d391cefd9fb25ac0c55859149a77ad Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 15:27:34 +0800 Subject: [PATCH 07/16] refactor(runtime): privatize Codex compaction projection Generated-by: Codex --- .../execution-model-composition.test.ts | 61 ++++++++++ .../__tests__/responses-wire-contract.test.ts | 110 ------------------ .../src/openai-codex-history-compactor.ts | 2 +- 3 files changed, 62 insertions(+), 111 deletions(-) diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 08b5b74e34..b808cc3928 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -964,6 +964,45 @@ test('Codex OAuth history compaction falls back to a text checkpoint after nativ }, }, }, + { + id: 'compact-provider-tool-call', + invocationId: 'compact-invocation', + runId: 'compact-same-route-run', + sessionId: 'backend-creation-session', + turnId: 'turn-current-route-model', + ts: 4, + partial: false, + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'compact-web-search', + name: 'WebSearch', + args: { query: 'latest Maka' }, + providerExecuted: true, + }, + refs: { stepId: 'compact-provider-step' }, + }, + { + id: 'compact-provider-tool-result', + invocationId: 'compact-invocation', + runId: 'compact-same-route-run', + sessionId: 'backend-creation-session', + turnId: 'turn-current-route-model', + ts: 5, + partial: false, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'compact-web-search', + name: 'WebSearch', + result: { type: 'web_search_result', query: 'latest Maka' }, + providerOutput: { type: 'web_search_result', id: 'ws_compact' }, + providerExecuted: true, + isError: false, + }, + }, compactRuntimeTextEvent( 'compact-recent-user', 'turn-recent-user', @@ -1023,6 +1062,28 @@ test('Codex OAuth history compaction falls back to a text checkpoint after nativ assert.match(nativeRequestText, /SAME_ROUTE_ENCRYPTED_REASONING/); assert.match(nativeRequestText, /recent context/); assert.doesNotMatch(nativeRequestText, /context summarization assistant/i); + const nativeInput = requests[0]!.body.input; + assert.ok(Array.isArray(nativeInput)); + const functionCallIds = new Set( + nativeInput + .filter( + (item): item is Record => + typeof item === 'object' && item !== null && item.type === 'function_call', + ) + .map((item) => String(item.call_id)), + ); + const functionOutputIds = nativeInput + .filter( + (item): item is Record => + typeof item === 'object' && item !== null && item.type === 'function_call_output', + ) + .map((item) => String(item.call_id)); + assert.deepEqual([...functionCallIds], ['compact-web-search']); + assert.deepEqual(functionOutputIds, ['compact-web-search']); + assert.deepEqual( + functionOutputIds.filter((callId) => !functionCallIds.has(callId)), + [], + ); assert.doesNotMatch(fallbackRequestText, /"type":"compaction_trigger"/); assert.match(fallbackRequestText, /context summarization assistant/i); assert.equal(result.outcome.kind, 'compacted'); diff --git a/packages/runtime/src/__tests__/responses-wire-contract.test.ts b/packages/runtime/src/__tests__/responses-wire-contract.test.ts index 0b666e8ebf..2bc67bafda 100644 --- a/packages/runtime/src/__tests__/responses-wire-contract.test.ts +++ b/packages/runtime/src/__tests__/responses-wire-contract.test.ts @@ -31,7 +31,6 @@ import { TOOL_SEARCH_PROVIDER_NAME } from '../tool-availability.js'; import { resolveModelRuntime } from '../model-runtime.js'; import { resolveRuntimeProviderAdapter } from '../provider-runtime-policy.js'; import { lowerModelTools } from '../model-adapter.js'; -import { openAiCodexCompactionMessages } from '../openai-codex-history-compactor.js'; import { openAiResponsesBaseUrl, openResponsesUrl } from '../provider-urls.js'; function conn(providerType: LlmConnection['providerType'], slug = 'test'): LlmConnection { @@ -590,115 +589,6 @@ describe('responses wire request body', () => { ); }); - test('keeps provider-executed tool history free of dangling outputs', async () => { - let body: Record | undefined; - const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { - body = JSON.parse(String(init?.body)); - return Response.json({ - id: 'r', - object: 'response', - status: 'completed', - output: [], - usage: { input_tokens: 1, output_tokens: 1 }, - }); - }) as unknown as typeof globalThis.fetch; - const event = ( - id: string, - role: RuntimeEvent['role'], - author: RuntimeEvent['author'], - content: RuntimeEvent['content'], - refs?: RuntimeEvent['refs'], - ): RuntimeEvent => ({ - id, - invocationId: 'inv-1', - runId: 'run-1', - sessionId: 'session-1', - turnId: 'turn-1', - ts: 1, - partial: false, - role, - author, - content, - ...(refs ? { refs } : {}), - }); - const messages = openAiCodexCompactionMessages( - [ - event('user', 'user', 'user', { kind: 'text', text: 'search' }), - event( - 'call', - 'model', - 'agent', - { - kind: 'function_call', - id: 'search-1', - name: 'WebSearch', - args: { query: 'latest Maka' }, - providerExecuted: true, - }, - { stepId: 'provider-step' }, - ), - event('result', 'tool', 'tool', { - kind: 'function_response', - id: 'search-1', - name: 'WebSearch', - result: { type: 'web_search_result', query: 'latest Maka' }, - providerOutput: { type: 'web_search_result', id: 'ws_123' }, - providerExecuted: true, - isError: false, - }), - event( - 'text', - 'model', - 'agent', - { kind: 'text', text: 'Maka shipped.' }, - { providerEventId: 'provider-step' }, - ), - ], - new Set(), - ); - assert.deepEqual( - messages.map((message) => ({ - role: message.role, - parts: - typeof message.content === 'string' ? ['text'] : message.content.map((part) => part.type), - })), - [ - { role: 'user', parts: ['text'] }, - { role: 'assistant', parts: ['tool-call'] }, - { role: 'tool', parts: ['tool-result'] }, - { role: 'assistant', parts: ['text'] }, - ], - ); - - const model = getAIModel({ - connection: conn('openai-codex', 'codex-subscription'), - apiKey: 'codex-token', - modelId: 'gpt-5.3-codex', - fetch, - }); - await model.doGenerate({ - prompt: messages as never, - providerOptions: { openai: { store: false, compactionTrigger: true } }, - }); - - const input = body?.input as Array>; - const callIds = new Set( - input.filter((item) => item.type === 'function_call').map((item) => String(item.call_id)), - ); - const danglingOutputIds = input - .filter((item) => item.type === 'function_call_output') - .map((item) => String(item.call_id)) - .filter((callId) => !callIds.has(callId)); - assert.deepEqual([...callIds], ['search-1']); - assert.deepEqual( - input - .filter((item) => item.type === 'function_call_output') - .map((item) => String(item.call_id)), - ['search-1'], - ); - assert.deepEqual(danglingOutputIds, [], JSON.stringify(input)); - }); - test('returns native apply_patch results with the provider output item', async () => { let body: Record | undefined; const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { diff --git a/packages/runtime/src/openai-codex-history-compactor.ts b/packages/runtime/src/openai-codex-history-compactor.ts index b100ea2417..86eb7626f2 100644 --- a/packages/runtime/src/openai-codex-history-compactor.ts +++ b/packages/runtime/src/openai-codex-history-compactor.ts @@ -188,7 +188,7 @@ function hasAbortCause(error: unknown): boolean { * Runtime persists tool calls/results before a step's reasoning/text closer; * grouping lets the compactor keep settled tool evidence before grounded text. */ -export function openAiCodexCompactionMessages( +function openAiCodexCompactionMessages( events: readonly RuntimeEvent[], providerReasoningReplayEventIds: ReadonlySet, ): ModelMessage[] { From 211fcd421ffa78836719e86d8db378192d771eac Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 16:41:30 +0800 Subject: [PATCH 08/16] fix(runtime): preserve ordered reasoning parts Generated-by: Codex --- .../computer-use-provider-protocol.test.ts | 99 ++++++++---- .../src/__tests__/model-adapter.test.ts | 19 ++- packages/runtime/src/ai-sdk-backend.ts | 143 +++++++++--------- packages/runtime/src/model-adapter.ts | 65 ++++---- packages/runtime/src/model-protocol.ts | 11 +- 5 files changed, 198 insertions(+), 139 deletions(-) diff --git a/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts b/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts index bb1a15fef7..e5ed84979f 100644 --- a/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts +++ b/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts @@ -50,7 +50,7 @@ after(async () => { }); describe('Anthropic-compatible Computer Use product loops', () => { - test('replays same-route redacted thinking through the Anthropic SDK converter', async () => { + test('replays every same-route Anthropic reasoning block in provider order', async () => { const sessionId = 'session-anthropic-redacted-replay'; const firstTurn = createDurableTurnHarness({ sessionId, @@ -70,11 +70,15 @@ describe('Anthropic-compatible Computer Use product loops', () => { assert.equal(request.url, '/v1/messages'); requestBodies.push(JSON.parse(await readBody(request)) as Record); if (requestBodies.length === 1) { - respondAnthropicRedactedStream( - response, - 'claude-sonnet-4-5-20250929', - 'opaque-redacted-thinking', - ); + respondAnthropicReasoningBlocksStream(response, 'claude-sonnet-4-5-20250929', [ + { + kind: 'signed', + text: 'inspect safely', + signature: 'signed-thinking-1', + }, + { kind: 'redacted', data: 'opaque-redacted-thinking-1' }, + { kind: 'redacted', data: 'opaque-redacted-thinking-2' }, + ]); } else { respondAnthropicStream(response, 'claude-sonnet-4-5-20250929', 2, undefined); } @@ -115,14 +119,28 @@ describe('Anthropic-compatible Computer Use product loops', () => { completedAt: 2, } satisfies AgentRunHeader; for await (const event of createRuntime().send(firstTurn.sendInput())) firstTurn.record(event); - assert.ok( - firstTurn.ledger.some( - (event) => - event.content?.kind === 'thinking' && - isRecord(event.content.providerOptions?.anthropic) && - event.content.providerOptions.anthropic.redactedData === 'opaque-redacted-thinking', - ), - 'ModelAdapter metadata must survive the RuntimeEvent durability boundary', + assert.deepEqual( + firstTurn.ledger + .filter( + ( + event, + ): event is RuntimeEvent & { + content: Extract, { kind: 'thinking' }>; + } => event.partial === false && event.content?.kind === 'thinking', + ) + .map((event) => [ + event.content.text, + event.content.signature, + isRecord(event.content.providerOptions?.anthropic) + ? event.content.providerOptions.anthropic.redactedData + : undefined, + ]), + [ + ['inspect safely', 'signed-thinking-1', undefined], + ['', undefined, 'opaque-redacted-thinking-1'], + ['', undefined, 'opaque-redacted-thinking-2'], + ], + 'ModelAdapter must preserve every ordered reasoning block at the RuntimeEvent boundary', ); for await (const event of createRuntime().send( @@ -135,10 +153,15 @@ describe('Anthropic-compatible Computer Use product loops', () => { } assert.equal(requestBodies.length, 2); - assert.ok( - collectRecords(requestBodies[1]!.messages).some( - (block) => block.type === 'redacted_thinking' && block.data === 'opaque-redacted-thinking', - ), + assert.deepEqual( + collectRecords(requestBodies[1]!.messages) + .filter((block) => block.type === 'thinking' || block.type === 'redacted_thinking') + .map((block) => [block.type, block.thinking, block.signature, block.data]), + [ + ['thinking', 'inspect safely', 'signed-thinking-1', undefined], + ['redacted_thinking', undefined, undefined, 'opaque-redacted-thinking-1'], + ['redacted_thinking', undefined, undefined, 'opaque-redacted-thinking-2'], + ], ); }); @@ -1409,10 +1432,13 @@ function respondOpenAiTextStream( response.end(); } -function respondAnthropicRedactedStream( +function respondAnthropicReasoningBlocksStream( response: ServerResponse, model: string, - redactedData: string, + blocks: readonly ( + | { kind: 'signed'; text: string; signature: string } + | { kind: 'redacted'; data: string } + )[], ) { response.writeHead(200, { 'content-type': 'text/event-stream', @@ -1434,18 +1460,35 @@ function respondAnthropicRedactedStream( usage: { input_tokens: 10, output_tokens: 0 }, }, }); + for (const [index, block] of blocks.entries()) { + send('content_block_start', { + type: 'content_block_start', + index, + content_block: + block.kind === 'signed' + ? { type: 'thinking', thinking: '' } + : { type: 'redacted_thinking', data: block.data }, + }); + if (block.kind === 'signed') { + send('content_block_delta', { + type: 'content_block_delta', + index, + delta: { type: 'thinking_delta', thinking: block.text }, + }); + send('content_block_delta', { + type: 'content_block_delta', + index, + delta: { type: 'signature_delta', signature: block.signature }, + }); + } + send('content_block_stop', { type: 'content_block_stop', index }); + } send('content_block_start', { type: 'content_block_start', - index: 0, - content_block: { type: 'redacted_thinking', data: redactedData }, - }); - send('content_block_stop', { type: 'content_block_stop', index: 0 }); - send('content_block_start', { - type: 'content_block_start', - index: 1, + index: blocks.length, content_block: { type: 'text', text: 'Inspection complete.' }, }); - send('content_block_stop', { type: 'content_block_stop', index: 1 }); + send('content_block_stop', { type: 'content_block_stop', index: blocks.length }); send('message_delta', { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, diff --git a/packages/runtime/src/__tests__/model-adapter.test.ts b/packages/runtime/src/__tests__/model-adapter.test.ts index 80b9afd998..3fa8751fe9 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -87,8 +87,7 @@ describe('ModelAdapter stream and error normalization', () => { }), [ { - kind: 'thinking', - text: '', + kind: 'thinking-start', providerOptions: { anthropic: { redactedData: 'opaque-redacted-thinking' } }, }, ], @@ -248,7 +247,7 @@ describe('ModelAdapter stream and error normalization', () => { }; assert.deepEqual( adapter.translateChunk({ type: 'reasoning-start', id: 'alibaba-reasoning-item' } as Chunk), - [{ kind: 'thinking', text: '', reasoningItemId: 'alibaba-reasoning-item' }], + [{ kind: 'thinking-start', reasoningPartId: 'alibaba-reasoning-item' }], ); assert.deepEqual( adapter.translateChunk({ @@ -256,7 +255,7 @@ describe('ModelAdapter stream and error normalization', () => { id: 'alibaba-reasoning-item', delta: 'summary', } as Chunk), - [{ kind: 'thinking', text: 'summary', reasoningItemId: 'alibaba-reasoning-item' }], + [{ kind: 'thinking', text: 'summary', reasoningPartId: 'alibaba-reasoning-item' }], ); assert.deepEqual( adapter.translateChunk({ @@ -274,7 +273,7 @@ describe('ModelAdapter stream and error normalization', () => { kind: 'thinking', text: '', providerOptions, - reasoningItemId: 'alibaba-reasoning-item', + reasoningPartId: 'alibaba-reasoning-item', reasoningSummaryText: 'summary', }, ], @@ -317,7 +316,13 @@ describe('ModelAdapter stream and error normalization', () => { id: 'deepseek-reasoning-item', delta: 'plaintext reasoning', } as Chunk), - [{ kind: 'thinking', text: 'plaintext reasoning' }], + [ + { + kind: 'thinking', + text: 'plaintext reasoning', + reasoningPartId: 'deepseek-reasoning-item', + }, + ], ); assert.deepEqual( adapter.translateChunk({ @@ -685,7 +690,7 @@ describe('ModelAdapter stream and error normalization', () => { assert.deepEqual( events.map((event) => event.kind), - ['thinking', 'thinking', 'thinking-signature'], + ['thinking-start', 'thinking', 'thinking', 'thinking-signature'], ); assert.deepEqual( events diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 279ea2d318..600317547e 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -1455,12 +1455,8 @@ export class AiSdkBackend implements AgentBackend { let stepText = ''; let stepTextProviderOptions: NonNullable | undefined; let stepTextPartStartOffset = 0; - let stepThinking = ''; - let sawStepThinking = false; - let stepThinkingProviderOptions: NonNullable | undefined; - let stepResponsesThinkingParts: AssistantThinkingPart[] = []; - let stepResponsesThinkingPartsByItemId = new Map(); - let stepSignature: string | undefined; + let stepThinkingParts: AssistantThinkingPart[] = []; + let stepThinkingPartsById = new Map(); const startedAt = this.now(); // Flush the current step's AssistantMessage (text + thinking) and the paired @@ -1473,21 +1469,10 @@ export class AiSdkBackend implements AgentBackend { // this step's assistant row. Hoisted to send() scope so both the streaming // path and the abort/error handler can flush a partial step. const flushStep = async (): Promise => { - const hasThinking = sawStepThinking || stepSignature !== undefined; + const hasThinking = stepThinkingParts.length > 0; if (stepText.length === 0 && !hasThinking) return; const stepId = currentStepMessageId; - const thinkingParts: AssistantThinkingPart[] = - stepResponsesThinkingParts.length > 0 - ? stepResponsesThinkingParts - : [ - { - text: stepThinking, - ...(stepSignature !== undefined ? { signature: stepSignature } : {}), - ...(stepThinkingProviderOptions !== undefined - ? { providerOptions: stepThinkingProviderOptions } - : {}), - }, - ]; + const thinkingText = stepThinkingParts.map((part) => part.text).join(''); const msg: AssistantMessage = { type: 'assistant', id: stepId, @@ -1501,21 +1486,22 @@ export class AiSdkBackend implements AgentBackend { ...(hasThinking ? { thinking: { - text: stepThinking, - ...(thinkingParts.length === 1 && thinkingParts[0]!.signature !== undefined - ? { signature: thinkingParts[0]!.signature } + text: thinkingText, + ...(stepThinkingParts.length === 1 && stepThinkingParts[0]!.signature !== undefined + ? { signature: stepThinkingParts[0]!.signature } : {}), - ...(thinkingParts.length === 1 && thinkingParts[0]!.providerOptions !== undefined - ? { providerOptions: thinkingParts[0]!.providerOptions } + ...(stepThinkingParts.length === 1 && + stepThinkingParts[0]!.providerOptions !== undefined + ? { providerOptions: stepThinkingParts[0]!.providerOptions } : {}), - ...(thinkingParts.length > 1 ? { parts: thinkingParts } : {}), + ...(stepThinkingParts.length > 1 ? { parts: stepThinkingParts } : {}), }, } : {}), }; await this.input.appendMessage(msg); if (hasThinking) { - for (const part of thinkingParts) { + for (const part of stepThinkingParts) { queue.push({ type: 'thinking_complete', id: this.newId(), @@ -1551,12 +1537,8 @@ export class AiSdkBackend implements AgentBackend { stepText = ''; stepTextProviderOptions = undefined; stepTextPartStartOffset = 0; - stepThinking = ''; - sawStepThinking = false; - stepThinkingProviderOptions = undefined; - stepResponsesThinkingParts = []; - stepResponsesThinkingPartsByItemId = new Map(); - stepSignature = undefined; + stepThinkingParts = []; + stepThinkingPartsById = new Map(); }; let tokenUsage: NormalizedAiSdkUsage | undefined; let tokenUsageCostUsd: number | undefined; @@ -2231,20 +2213,32 @@ export class AiSdkBackend implements AgentBackend { >, stepTextPartStartOffset, ); + } else if (event.kind === 'thinking-start') { + if (event.providerOptions !== undefined) { + attemptSawContinuationMetadata = true; + } + const part: AssistantThinkingPart = { + text: '', + ...(event.providerOptions !== undefined + ? { providerOptions: event.providerOptions } + : {}), + }; + stepThinkingParts.push(part); + if (event.reasoningPartId) { + stepThinkingPartsById.set(event.reasoningPartId, part); + } } else if (event.kind === 'thinking') { - sawStepThinking = true; - stepThinking += event.text; if (event.text.length > 0) attemptSawThinking = true; if (event.providerOptions !== undefined) { if (event.providerOptionsOrigin !== 'maka_transport') { attemptSawContinuationMetadata = true; } - stepThinkingProviderOptions = event.providerOptions; } - const itemId = - event.reasoningItemId ?? responsesReasoningItemId(event.providerOptions); - if (typeof itemId === 'string' && itemId.length > 0) { - let part = stepResponsesThinkingPartsByItemId.get(itemId); + const partId = + event.reasoningPartId ?? responsesReasoningItemId(event.providerOptions); + let part: AssistantThinkingPart | undefined; + if (typeof partId === 'string' && partId.length > 0) { + part = stepThinkingPartsById.get(partId); if ( part && event.providerOptions === undefined && @@ -2254,46 +2248,43 @@ export class AiSdkBackend implements AgentBackend { // output_item.done. Keep it out of the finalized item or // its durable summary boundaries will no longer match. part = { text: '' }; - stepResponsesThinkingParts.push(part); - stepResponsesThinkingPartsByItemId.set(itemId, part); + stepThinkingParts.push(part); + stepThinkingPartsById.set(partId, part); } if (!part) { - part = { - text: - stepResponsesThinkingParts.length === 0 && event.text.length === 0 - ? stepThinking - : '', - }; - stepResponsesThinkingParts.push(part); - stepResponsesThinkingPartsByItemId.set(itemId, part); + part = { text: '' }; + stepThinkingParts.push(part); + stepThinkingPartsById.set(partId, part); } - const nextPartText = part.text + event.text; + } else { + part = stepThinkingParts.at(-1); if ( - event.reasoningSummaryText !== undefined && - event.reasoningSummaryText !== nextPartText + part && + decodePlaintextResponsesReasoningState(part.providerOptions).kind === 'valid' ) { - throw new Error( - 'Streamed plaintext Responses reasoning does not match final provider summary', - ); - } - part.text = nextPartText; - if (event.providerOptions !== undefined) { - part.providerOptions = event.providerOptions; - } - } else if (stepResponsesThinkingParts.length > 0) { - const lastPart = stepResponsesThinkingParts.at(-1)!; - const lastState = decodePlaintextResponsesReasoningState( - lastPart.providerOptions, - ); - if (lastState.kind === 'valid') { // An invalid next item has no usable stream id. Do not // append its deltas to the finalized item: partial-error // flush must keep that item's durable boundaries valid. - stepResponsesThinkingParts.push({ text: event.text }); - } else { - lastPart.text += event.text; + part = undefined; } } + if (!part) { + part = { text: '' }; + stepThinkingParts.push(part); + } + const nextPartText = part.text + event.text; + if ( + event.reasoningSummaryText !== undefined && + event.reasoningSummaryText !== nextPartText + ) { + throw new Error( + 'Streamed plaintext Responses reasoning does not match final provider summary', + ); + } + part.text = nextPartText; + if (event.providerOptions !== undefined) { + part.providerOptions = event.providerOptions; + } queue.push({ type: 'thinking_delta', id: this.newId(), @@ -2304,7 +2295,17 @@ export class AiSdkBackend implements AgentBackend { } satisfies ThinkingDeltaEvent); } else if (event.kind === 'thinking-signature') { attemptSawContinuationMetadata = true; - stepSignature = event.signature; + let part = event.reasoningPartId + ? stepThinkingPartsById.get(event.reasoningPartId) + : stepThinkingParts.at(-1); + if (!part) { + part = { text: '' }; + stepThinkingParts.push(part); + if (event.reasoningPartId) { + stepThinkingPartsById.set(event.reasoningPartId, part); + } + } + part.signature = event.signature; } else if (event.kind === 'provider-tool-input') { // The provider has started its own tool. Even without a // final tool-call/result event, retrying can repeat external @@ -2469,7 +2470,7 @@ export class AiSdkBackend implements AgentBackend { ) { if (idleWatchdogRecovery) { idleWatchdogRetryCount += 1; - if (stepThinking.length > 0) { + if (stepThinkingParts.length > 0) { await flushStep(); currentStepMessageId = this.newId(); } diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index 2393d06113..b5444bb529 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -761,6 +761,7 @@ function requireResponsesReplayProfile(runtime: ResolvedModelRuntime): string { */ interface AiSdkStreamChunk { type: string; + id?: unknown; text?: string; delta?: string; textDelta?: string; @@ -948,17 +949,10 @@ function plaintextSummaryTextFromChunk( return plaintextSummaryParts(provider)?.join(''); } -function plaintextSummaryItemIdFromChunk( - chunk: AiSdkStreamChunk, - runtime: ResolvedModelRuntime | undefined, -): string | undefined { - if ( - runtime?.reasoningReplay.kind !== 'responses' || - runtime.reasoningReplay.contract.reasoningReplay !== 'plaintext-summary' - ) { - return undefined; - } - return safePlaintextResponsesReasoningItemId((chunk as { id?: unknown }).id); +function reasoningPartIdFromChunk(chunk: AiSdkStreamChunk): string | undefined { + return typeof chunk.id === 'string' && chunk.id.length > 0 && chunk.id.length <= 512 + ? chunk.id + : undefined; } /** @@ -974,21 +968,18 @@ function translateChunk( ): ModelStreamEvent[] { switch (chunk.type) { case 'reasoning-start': { - const reasoningItemId = plaintextSummaryItemIdFromChunk(chunk, runtime); + const reasoningPartId = reasoningPartIdFromChunk(chunk); const redactedThinkingProviderOptions = anthropicRedactedThinkingProviderOptionsFromChunk(chunk); - return reasoningItemId || redactedThinkingProviderOptions - ? [ - { - kind: 'thinking', - text: '', - ...(redactedThinkingProviderOptions - ? { providerOptions: redactedThinkingProviderOptions } - : {}), - ...(reasoningItemId ? { reasoningItemId } : {}), - }, - ] - : []; + return [ + { + kind: 'thinking-start', + ...(redactedThinkingProviderOptions + ? { providerOptions: redactedThinkingProviderOptions } + : {}), + ...(reasoningPartId ? { reasoningPartId } : {}), + }, + ]; } case 'text-start': return [{ kind: 'text-start' }]; @@ -1019,10 +1010,16 @@ function translateChunk( const responsesProviderOptions = runtime ? openAiResponsesReasoningProviderOptionsFromChunk(chunk, runtime) : undefined; - const reasoningItemId = plaintextSummaryItemIdFromChunk(chunk, runtime); + const reasoningPartId = reasoningPartIdFromChunk(chunk); const reasoningSummaryText = plaintextSummaryTextFromChunk(chunk, runtime); const events: ModelStreamEvent[] = []; - if (signature) events.push({ kind: 'thinking-signature', signature }); + if (signature) { + events.push({ + kind: 'thinking-signature', + signature, + ...(reasoningPartId ? { reasoningPartId } : {}), + }); + } // The signed reasoning chunk arrives as a standalone delta with empty // text; preserve provider-authored empty reasoning, but do not surface a // signature-only carrier as an additional empty reasoning fragment. @@ -1040,7 +1037,7 @@ function translateChunk( providerOptionsOrigin: 'maka_transport' as const, } : {}), - ...(reasoningItemId ? { reasoningItemId } : {}), + ...(reasoningPartId ? { reasoningPartId } : {}), ...(reasoningSummaryText !== undefined ? { reasoningSummaryText } : {}), }); } @@ -1051,17 +1048,25 @@ function translateChunk( const responsesProviderOptions = runtime ? openAiResponsesReasoningProviderOptionsFromChunk(chunk, runtime) : undefined; - const reasoningItemId = plaintextSummaryItemIdFromChunk(chunk, runtime); + const reasoningPartId = reasoningPartIdFromChunk(chunk); const reasoningSummaryText = plaintextSummaryTextFromChunk(chunk, runtime); return [ - ...(signature ? [{ kind: 'thinking-signature' as const, signature }] : []), + ...(signature + ? [ + { + kind: 'thinking-signature' as const, + signature, + ...(reasoningPartId ? { reasoningPartId } : {}), + }, + ] + : []), ...(responsesProviderOptions ? [ { kind: 'thinking' as const, text: '', providerOptions: responsesProviderOptions, - ...(reasoningItemId ? { reasoningItemId } : {}), + ...(reasoningPartId ? { reasoningPartId } : {}), ...(reasoningSummaryText !== undefined ? { reasoningSummaryText } : {}), }, ] diff --git a/packages/runtime/src/model-protocol.ts b/packages/runtime/src/model-protocol.ts index be9c0d0c8c..8e771cc3ff 100644 --- a/packages/runtime/src/model-protocol.ts +++ b/packages/runtime/src/model-protocol.ts @@ -389,18 +389,23 @@ export type ModelStreamEvent = | { kind: 'text-start' } | { kind: 'text'; text: string } | { kind: 'text-metadata'; providerOptions: ProviderOptions } + | { + kind: 'thinking-start'; + reasoningPartId?: string; + providerOptions?: ProviderOptions; + } | { kind: 'thinking'; text: string; providerOptions?: ProviderOptions; - /** Bounded item identity used only while grouping one streamed reasoning item. */ - reasoningItemId?: string; + /** Bounded SDK-local identity used only while grouping one streamed reasoning part. */ + reasoningPartId?: string; /** Final provider summary, compared before only its part boundaries are persisted. */ reasoningSummaryText?: string; /** Maka-authored replay hint; absent provider metadata stays fail-closed. */ providerOptionsOrigin?: 'maka_transport'; } - | { kind: 'thinking-signature'; signature: string } + | { kind: 'thinking-signature'; signature: string; reasoningPartId?: string } /** Provider-side tool execution has begun, but no replayable call exists yet. */ | { kind: 'provider-tool-input' } | { kind: 'tool-call'; toolCall: ToolCallPart } From 7a54ab0ba1df5a58cce8bb2035d4b931903b0d1f Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 16:42:08 +0800 Subject: [PATCH 09/16] fix(runtime): bind replay admission to target route Generated-by: Codex --- .../__tests__/runtime-continuation.test.ts | 2 +- .../src/__tests__/session-manager.test.ts | 33 +++++++++++++++---- packages/runtime/src/continuation-replay.ts | 27 ++++++++++----- packages/runtime/src/runtime-kernel.ts | 10 ++++-- 4 files changed, 53 insertions(+), 19 deletions(-) diff --git a/packages/runtime/src/__tests__/runtime-continuation.test.ts b/packages/runtime/src/__tests__/runtime-continuation.test.ts index d2f7594e74..e9e109c109 100644 --- a/packages/runtime/src/__tests__/runtime-continuation.test.ts +++ b/packages/runtime/src/__tests__/runtime-continuation.test.ts @@ -30,7 +30,7 @@ import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { AgentRunHeader } from '@maka/core/agent-run'; import { createLocalContinuationSafetyInspector } from '../continuation-safety.js'; -import { buildContinuationReplayPlan, digestProviderReplay } from '../continuation-replay.js'; +import { buildContinuationReplayPlan } from '../continuation-replay.js'; import { buildRuntimeEventModelReplayPlan, PROVIDER_REPLAY_PROJECTION_VERSION, diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 32e810432f..bb18e7f9ff 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -137,8 +137,6 @@ import { claimAgentGraphRunnableIntent, fingerprintAgentGraphRunnableIntent, } from '../stream-graph-admission.js'; -import { digestProviderReplay } from '../continuation-replay.js'; -import { buildRuntimeEventModelReplayPlan } from '../model-history.js'; import type { AgentGraphRunnableIntent } from '../stream-graph-readiness.js'; test('sendMessage rejects removed Automation as a live trigger', async () => { @@ -5390,7 +5388,7 @@ describe('SessionManager permission mode updates', () => { ); }); - test('authenticates the same cross-route continuation projection that reaches the provider', async () => { + test('authenticates the exact target-aware continuation projection that reaches the provider', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); @@ -5601,6 +5599,28 @@ describe('SessionManager permission mode updates', () => { ); expect(providerRequest).toBe(undefined); + const sameProjectionStalePlan = await manager.planSafeBoundaryContinuation( + session.id, + planInput, + ); + expect(sameProjectionStalePlan.disposition).toBe('continue'); + if (!sameProjectionStalePlan.continuation) { + throw new Error('expected same-projection stale continuation'); + } + await store.updateHeader(session.id, { + llmConnectionId: 'connection-c', + llmConnectionSlug: 'anthropic-c', + model: 'claude-c', + }); + await assert.rejects( + () => + collectSessionEvents( + manager.resumeSafeBoundaryContinuation(sameProjectionStalePlan.continuation!), + ), + /replay changed after planning/, + ); + expect(providerRequest).toBe(undefined); + const plan = await manager.planSafeBoundaryContinuation(session.id, planInput); expect(plan.disposition).toBe('continue'); if (!plan.continuation) throw new Error('expected continuation'); @@ -5616,10 +5636,9 @@ describe('SessionManager permission mode updates', () => { assert.match(promptJson, /continue across routes/, promptJson); expect(promptJson).toContain('cross-route-read'); expect(promptJson).toContain('package contents'); - const rawReplay = buildRuntimeEventModelReplayPlan(plan.continuation.runtimeContext); - const admittedReplayItems = rawReplay.items.filter((item) => item.kind !== 'thinking'); - expect(plan.continuation.providerReplayDigest).toBe( - digestProviderReplay(1, admittedReplayItems), + assert.notEqual( + plan.continuation.providerReplayDigest, + sameProjectionStalePlan.continuation.providerReplayDigest, ); const continuationEvents = await runStore.readRuntimeEvents( session.id, diff --git a/packages/runtime/src/continuation-replay.ts b/packages/runtime/src/continuation-replay.ts index bfa6b805a2..3a377a4139 100644 --- a/packages/runtime/src/continuation-replay.ts +++ b/packages/runtime/src/continuation-replay.ts @@ -128,7 +128,12 @@ export function buildContinuationReplayPlan(input: { protocol: 'continuation_replay_plan_v1', providerProjectionVersion: input.providerProjectionVersion, boundary: createRuntimeBoundaryCursor(boundaries), - providerReplayDigest: digestProviderReplay(input.providerProjectionVersion, providerItems), + providerReplayDigest: digestProviderReplayAdmission({ + providerProjectionVersion: input.providerProjectionVersion, + targetConnectionId: input.admissionRoute.targetConnectionId, + targetModelId: input.admissionRoute.targetModelId, + items: providerItems, + }), segments, runtimeContext, providerItems, @@ -268,14 +273,20 @@ function isBlockingProjectionDiagnostic(diagnostic: RuntimeEventReplayDiagnostic ); } -export function digestProviderReplay( - providerProjectionVersion: number, - items: readonly RuntimeEventModelReplayItem[], -): RuntimeBoundaryDigest { +export function digestProviderReplayAdmission(input: { + providerProjectionVersion: number; + targetConnectionId: string | undefined; + targetModelId: string; + items: readonly RuntimeEventModelReplayItem[]; +}): RuntimeBoundaryDigest { const json = stableJsonStringify({ - protocol: 'provider_replay_plan_v1', - providerProjectionVersion, - items, + protocol: 'provider_replay_admission_v1', + providerProjectionVersion: input.providerProjectionVersion, + target: { + connectionId: input.targetConnectionId ?? null, + modelId: input.targetModelId, + }, + items: input.items, }); return `sha256:${createHash('sha256').update(json, 'utf8').digest('hex')}`; } diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index ebcbfa97e1..146dc3c986 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -116,7 +116,7 @@ import { } from './runtime-resume.js'; import { buildContinuationReplayPlan, - digestProviderReplay, + digestProviderReplayAdmission, type ContinuationReplayAdmissionRoute, } from './continuation-replay.js'; import { @@ -2917,8 +2917,12 @@ function consumeAdmittedRuntimeContinuation(input: { providerReasoningReplayEventIds, ); if ( - digestProviderReplay(continuation.providerProjectionVersion, admittedItems) !== - continuation.providerReplayDigest + digestProviderReplayAdmission({ + providerProjectionVersion: continuation.providerProjectionVersion, + targetConnectionId: input.admissionRoute.targetConnectionId, + targetModelId: input.admissionRoute.targetModelId, + items: admittedItems, + }) !== continuation.providerReplayDigest ) { throw new Error('Runtime continuation provider replay identity changed after admission'); } From 2d70633626b535b09ed05e3d55ddd6a5c0a585dd Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 16:42:42 +0800 Subject: [PATCH 10/16] refactor(runtime): remove segment replay metadata Generated-by: Codex --- packages/runtime/src/continuation-replay.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/runtime/src/continuation-replay.ts b/packages/runtime/src/continuation-replay.ts index 3a377a4139..4f49f2d560 100644 --- a/packages/runtime/src/continuation-replay.ts +++ b/packages/runtime/src/continuation-replay.ts @@ -45,8 +45,6 @@ export interface ContinuationReplaySegmentV1 { } export interface ContinuationReplaySegmentPlanV1 { - protocol: 'continuation_replay_segment_plan_v1'; - providerProjectionVersion: typeof PROVIDER_REPLAY_PROJECTION_VERSION; segment: ContinuationReplaySegmentV1; providerItems: readonly RuntimeEventModelReplayItem[]; } @@ -241,8 +239,6 @@ export function buildContinuationReplaySegment(input: { return { kind: 'replayable', plan: { - protocol: 'continuation_replay_segment_plan_v1', - providerProjectionVersion: input.providerProjectionVersion, segment: { boundary: runtimePrefixSegment(input.prefix), replayRuntimeEvents, From 7aaceea986e22a8bbdfd712e9a95b2a70263c1ce Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 19:04:18 +0800 Subject: [PATCH 11/16] feat(runtime): read provider replay projection v2 Generated-by: Codex --- .../core/src/__tests__/runtime-event.test.ts | 13 +++- packages/core/src/runtime-boundary.ts | 6 +- packages/core/src/runtime-event.ts | 4 +- .../src/runtime-continuation-admission.ts | 4 +- .../__tests__/sqlite-runtime-schema.test.ts | 72 +++++++++++++++++++ packages/storage/src/sqlite-runtime-schema.ts | 43 ++++++++++- 6 files changed, 132 insertions(+), 10 deletions(-) diff --git a/packages/core/src/__tests__/runtime-event.test.ts b/packages/core/src/__tests__/runtime-event.test.ts index f72f1785cf..2e6725bfd3 100644 --- a/packages/core/src/__tests__/runtime-event.test.ts +++ b/packages/core/src/__tests__/runtime-event.test.ts @@ -130,7 +130,7 @@ test('shares one decoder across all TurnOrigin variants', () => { }); describe('continuation-start protocol', () => { - test('accepts only the replay projection version defined by v2', () => { + test('reads legacy and current replay projections but rejects unknown versions', () => { const continuationStart = { protocol: 'continuation_start_v2', provenance: 'runtime_admission', @@ -162,12 +162,21 @@ describe('continuation-start protocol', () => { ).actions?.continuationStart, continuationStart, ); + assert.equal( + decodeRuntimeEvent({ + ...baseEvent({ role: 'system', author: 'system', content: undefined }), + actions: { + continuationStart: { ...continuationStart, providerProjectionVersion: 2 }, + }, + }).actions?.continuationStart?.providerProjectionVersion, + 2, + ); assert.throws( () => decodeRuntimeEvent({ ...baseEvent({ role: 'system', author: 'system', content: undefined }), actions: { - continuationStart: { ...continuationStart, providerProjectionVersion: 2 }, + continuationStart: { ...continuationStart, providerProjectionVersion: 3 }, }, }), /RuntimeEvent schema/, diff --git a/packages/core/src/runtime-boundary.ts b/packages/core/src/runtime-boundary.ts index e444a8862a..6408b67b3b 100644 --- a/packages/core/src/runtime-boundary.ts +++ b/packages/core/src/runtime-boundary.ts @@ -71,7 +71,7 @@ export interface ContinuationClaimV1 { claimId: string; boundaryDigest: RuntimeBoundaryDigest; boundary: RuntimeBoundaryCursorV1; - providerProjectionVersion: 1; + providerProjectionVersion: 1 | 2; providerReplayDigest: RuntimeBoundaryDigest; target: { sessionId: string; @@ -242,7 +242,7 @@ export function decodeContinuationClaim(value: unknown): ContinuationClaimV1 { !isNonEmptyString(value.target.invocationId) || !isNonEmptyString(value.target.runId) || !isNonEmptyString(value.target.turnId) || - value.providerProjectionVersion !== 1 || + (value.providerProjectionVersion !== 1 && value.providerProjectionVersion !== 2) || !Number.isSafeInteger(value.claimedAt) || (value.claimedAt as number) < 0 ) { @@ -302,7 +302,7 @@ export function decodeContinuationClaim(value: unknown): ContinuationClaimV1 { claimId: value.claimId, boundaryDigest, boundary, - providerProjectionVersion: 1, + providerProjectionVersion: value.providerProjectionVersion, providerReplayDigest, target: { sessionId: value.target.sessionId, diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index 8dbe8e31e9..03b22078f7 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -336,7 +336,7 @@ export interface RuntimeEventContinuationStartV2 { prefixDigest: `sha256:${string}`; }; replayManifestDigest: `sha256:${string}`; - providerProjectionVersion: 1; + providerProjectionVersion: 1 | 2; providerReplayDigest: `sha256:${string}`; } @@ -1030,7 +1030,7 @@ function isRuntimeContinuationStart(value: unknown): value is RuntimeEventContin (value.immediateSource.highWater as number) > 0 && isSha256Digest(value.immediateSource.prefixDigest) && isSha256Digest(value.replayManifestDigest) && - value.providerProjectionVersion === 1 && + (value.providerProjectionVersion === 1 || value.providerProjectionVersion === 2) && isSha256Digest(value.providerReplayDigest) ); } diff --git a/packages/runtime/src/runtime-continuation-admission.ts b/packages/runtime/src/runtime-continuation-admission.ts index cc7a3142e1..bbbf11bb91 100644 --- a/packages/runtime/src/runtime-continuation-admission.ts +++ b/packages/runtime/src/runtime-continuation-admission.ts @@ -34,7 +34,7 @@ export interface RuntimeContinuationStartAdmissionIdentity { startEventId: string; claimId: string; boundaryDigest: `sha256:${string}`; - providerProjectionVersion: 1; + providerProjectionVersion: 1 | 2; providerReplayDigest: `sha256:${string}`; toolBoundaryProtocol?: ToolBoundaryProtocol; target: { @@ -54,7 +54,7 @@ export function createRuntimeContinuationStartAdmissionProof( identity.startEventId.length === 0 || identity.claimId.length === 0 || !/^sha256:[0-9a-f]{64}$/.test(identity.boundaryDigest) || - identity.providerProjectionVersion !== 1 || + (identity.providerProjectionVersion !== 1 && identity.providerProjectionVersion !== 2) || !/^sha256:[0-9a-f]{64}$/.test(identity.providerReplayDigest) || (identity.toolBoundaryProtocol !== undefined && identity.toolBoundaryProtocol !== TOOL_BOUNDARY_PROTOCOL_V1) || diff --git a/packages/storage/src/__tests__/sqlite-runtime-schema.test.ts b/packages/storage/src/__tests__/sqlite-runtime-schema.test.ts index 1469e639c0..e8d45e944e 100644 --- a/packages/storage/src/__tests__/sqlite-runtime-schema.test.ts +++ b/packages/storage/src/__tests__/sqlite-runtime-schema.test.ts @@ -102,4 +102,76 @@ describe('SQLite runtime schema migration', () => { real.close(); } }); + + it('preserves v1 continuation claims while admitting the v2 replay projection', () => { + const db = new DatabaseSync(':memory:'); + try { + db.exec('PRAGMA foreign_keys = ON'); + db.exec(` + CREATE TABLE runtime_events (event_id TEXT PRIMARY KEY); + CREATE TABLE runtime_continuation_claims ( + claim_id TEXT PRIMARY KEY, + source_session_id TEXT NOT NULL, + source_invocation_id TEXT NOT NULL, + source_run_id TEXT NOT NULL, + source_turn_id TEXT NOT NULL, + source_event_high_water INTEGER NOT NULL CHECK (source_event_high_water > 0), + source_prefix_digest TEXT NOT NULL, + boundary_digest TEXT NOT NULL UNIQUE, + boundary_json TEXT NOT NULL, + provider_projection_version INTEGER NOT NULL CHECK (provider_projection_version = 1), + provider_replay_digest TEXT NOT NULL, + target_session_id TEXT NOT NULL, + target_invocation_id TEXT NOT NULL UNIQUE, + target_run_id TEXT NOT NULL UNIQUE, + target_turn_id TEXT NOT NULL, + target_run_header_json TEXT NOT NULL, + claimed_at INTEGER NOT NULL, + start_event_id TEXT UNIQUE REFERENCES runtime_events(event_id), + start_kind TEXT CHECK (start_kind IS NULL OR start_kind IN ('runtime_admission', 'claim_repair')), + protocol_version INTEGER NOT NULL CHECK (protocol_version = 1), + UNIQUE (source_session_id, source_run_id, source_event_high_water, source_prefix_digest), + UNIQUE (target_session_id, target_turn_id) + ); + INSERT INTO runtime_continuation_claims VALUES ( + 'claim-v1', 'session', 'source-invocation', 'source-run', 'source-turn', 1, + 'sha256:source', 'sha256:boundary-v1', '{}', 1, 'sha256:replay-v1', + 'session', 'target-invocation-v1', 'target-run-v1', 'target-turn-v1', '{}', + 1, NULL, NULL, 1 + ); + PRAGMA user_version = 14; + `); + + migrateSqliteRuntimeDatabase(db); + + assert.equal(SQLITE_RUNTIME_SCHEMA_VERSION, 15); + assert.equal( + ( + db + .prepare( + "SELECT provider_projection_version AS version FROM runtime_continuation_claims WHERE claim_id = 'claim-v1'", + ) + .get() as { version: number } + ).version, + 1, + ); + db.exec(` + INSERT INTO runtime_continuation_claims VALUES ( + 'claim-v2', 'session', 'source-invocation', 'source-run', 'source-turn', 2, + 'sha256:source-2', 'sha256:boundary-v2', '{}', 2, 'sha256:replay-v2', + 'session', 'target-invocation-v2', 'target-run-v2', 'target-turn-v2', '{}', + 2, NULL, NULL, 1 + ); + `); + assert.throws(() => + db.exec(` + UPDATE runtime_continuation_claims + SET provider_projection_version = 3 + WHERE claim_id = 'claim-v2' + `), + ); + } finally { + db.close(); + } + }); }); diff --git a/packages/storage/src/sqlite-runtime-schema.ts b/packages/storage/src/sqlite-runtime-schema.ts index 47add708ee..d9ecaee2cd 100644 --- a/packages/storage/src/sqlite-runtime-schema.ts +++ b/packages/storage/src/sqlite-runtime-schema.ts @@ -19,7 +19,7 @@ import type { DatabaseSync } from 'node:sqlite'; -export const SQLITE_RUNTIME_SCHEMA_VERSION = 14; +export const SQLITE_RUNTIME_SCHEMA_VERSION = 15; export const RUNTIME_RECOVERY_AUTHORITY_CAPABILITY = 'runtime_recovery_authority'; export const RUNTIME_RECOVERY_AUTHORITY_CAPABILITY_VERSION = 1; export const RUNTIME_CONTINUATION_AUTHORITY_CAPABILITY = 'runtime_continuation_authority'; @@ -443,6 +443,47 @@ const MIGRATIONS: ReadonlyMap = new Map([ ); `, ], + [ + 15, + ` + CREATE TABLE runtime_continuation_claims_v15 ( + claim_id TEXT PRIMARY KEY, + source_session_id TEXT NOT NULL, + source_invocation_id TEXT NOT NULL, + source_run_id TEXT NOT NULL, + source_turn_id TEXT NOT NULL, + source_event_high_water INTEGER NOT NULL CHECK (source_event_high_water > 0), + source_prefix_digest TEXT NOT NULL, + boundary_digest TEXT NOT NULL UNIQUE, + boundary_json TEXT NOT NULL, + provider_projection_version INTEGER NOT NULL CHECK (provider_projection_version IN (1, 2)), + provider_replay_digest TEXT NOT NULL, + target_session_id TEXT NOT NULL, + target_invocation_id TEXT NOT NULL UNIQUE, + target_run_id TEXT NOT NULL UNIQUE, + target_turn_id TEXT NOT NULL, + target_run_header_json TEXT NOT NULL, + claimed_at INTEGER NOT NULL, + start_event_id TEXT UNIQUE REFERENCES runtime_events(event_id), + start_kind TEXT CHECK ( + start_kind IS NULL OR start_kind IN ('runtime_admission', 'claim_repair') + ), + protocol_version INTEGER NOT NULL CHECK (protocol_version = 1), + UNIQUE ( + source_session_id, + source_run_id, + source_event_high_water, + source_prefix_digest + ), + UNIQUE (target_session_id, target_turn_id) + ); + + INSERT INTO runtime_continuation_claims_v15 + SELECT * FROM runtime_continuation_claims; + DROP TABLE runtime_continuation_claims; + ALTER TABLE runtime_continuation_claims_v15 RENAME TO runtime_continuation_claims; + `, + ], ]); export function configureSqliteRuntimeDatabase(db: DatabaseSync): void { From 4e32193b6842b557d7198f3f98bd8632f0b3e4a3 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 19:05:51 +0800 Subject: [PATCH 12/16] fix(runtime): bind reasoning replay to provider state Generated-by: Codex --- packages/core/src/agent-run.ts | 10 +++ .../connection-effect-coordinator.test.ts | 55 +++++++++++++++ .../execution-model-composition.test.ts | 18 +++++ .../src/server/execution-composition.ts | 3 + .../src/server/execution-model-authority.ts | 67 +++++++++++++++++-- .../src/server/execution-model-composition.ts | 8 +++ .../src/__tests__/ai-sdk-backend.test.ts | 55 +++++++++++++++ .../src/__tests__/continuation-replay.test.ts | 17 ++++- .../execution-boundary-test-helpers.ts | 1 + .../__tests__/runtime-continuation.test.ts | 4 +- .../src/__tests__/runtime-resume.test.ts | 2 +- .../src/__tests__/session-manager.test.ts | 34 ++++++---- packages/runtime/src/agent-run.ts | 11 +++ packages/runtime/src/ai-sdk-backend.ts | 7 +- packages/runtime/src/ai-sdk-compaction.ts | 5 +- packages/runtime/src/continuation-replay.ts | 14 ++-- packages/runtime/src/model-history.ts | 11 +-- .../src/openai-codex-history-compactor.ts | 3 +- packages/runtime/src/runtime-kernel.ts | 18 ++++- packages/runtime/src/runtime-resume.ts | 10 ++- packages/runtime/src/session-manager.ts | 6 +- 21 files changed, 313 insertions(+), 46 deletions(-) diff --git a/packages/core/src/agent-run.ts b/packages/core/src/agent-run.ts index e85e0c5608..5b69d3dc0e 100644 --- a/packages/core/src/agent-run.ts +++ b/packages/core/src/agent-run.ts @@ -161,6 +161,12 @@ export interface AgentRunHeader { backendKind: PersistedBackendKind; /** Immutable Connection entity identity. Optional only on legacy run headers. */ llmConnectionId?: string; + /** + * Opaque identity of the provider endpoint and credential ownership frozen + * before this run's first provider dispatch. Optional only on legacy or + * non-provider run headers. + */ + providerStateIdentity?: `sha256:${string}`; llmConnectionSlug: string; modelId: string; cwd: string; @@ -568,6 +574,7 @@ const AGENT_RUN_HEADER_SHAPE = defineObjectShape()( [ 'invocationId', 'llmConnectionId', + 'providerStateIdentity', 'completedAt', 'parentRunId', 'resumedFromRunId', @@ -646,6 +653,9 @@ export function decodeAgentRunHeader(value: unknown): AgentRunHeader { isPersistedBackendKind(value.backendKind) && (value.llmConnectionId === undefined || (typeof value.llmConnectionId === 'string' && value.llmConnectionId.length > 0)) && + (value.providerStateIdentity === undefined || + (typeof value.providerStateIdentity === 'string' && + /^sha256:[0-9a-f]{64}$/.test(value.providerStateIdentity))) && typeof value.llmConnectionSlug === 'string' && typeof value.modelId === 'string' && typeof value.cwd === 'string' && diff --git a/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts b/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts index 6dddac0bb8..6f04991bc0 100644 --- a/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts @@ -41,6 +41,7 @@ import { HostConnectionEffectCoordinator } from '../server/connection-effect-coo import { HostOAuthExecutionAuthority } from '../server/oauth-execution-authority.js'; import type { ConnectionContext } from '../server/operation-dispatcher.js'; import { RuntimePolicyActivationGate } from '../server/runtime-policy-activation-gate.js'; +import { resolveExecutionProviderStateIdentity } from '../server/execution-model-authority.js'; import type { ConnectionOnboardingSaveResult, OperationOutcome } from '../protocol/index.js'; const context: ConnectionContext = { @@ -490,6 +491,60 @@ test('a save whose connection changed between discovery and commit is superseded }); }); +test('provider state identity follows endpoint, credential, and request-header ownership', async () => { + await withFixture(async ({ stores }) => { + const connection = await createConnection(stores, 0, { + ...connectionDraft('identity-relay', 'openai-compatible'), + baseUrl: 'https://relay-a.example.test/v1', + }); + await setConnectionCredential(stores, connection, 'key-a'); + const header = { + llmConnectionId: connection.connectionId, + llmConnectionSlug: connection.slug, + model: 'gpt-5', + }; + const initial = await resolveExecutionProviderStateIdentity(header, stores); + + const moved = await stores.connectionCatalog.update({ + expected: { connectionId: connection.connectionId, revision: connection.revision }, + changes: { + name: connection.name, + baseUrl: 'https://relay-b.example.test/v1', + enabled: true, + enabledModelIds: connection.enabledModelIds, + }, + }); + assert.equal(moved.kind, 'committed'); + const afterEndpoint = await resolveExecutionProviderStateIdentity(header, stores); + assert.notEqual(afterEndpoint, initial); + + const status = await connectionCredentialStatus(stores, connection); + assert.equal(status.configured, true); + if (!status.configured) return; + const rotated = await stores.credentialVault.set({ + locator: connectionCredential(connection), + expected: { credentialId: status.credentialId, revision: status.revision }, + secret: 'key-b', + }); + assert.equal(rotated.kind, 'committed'); + const afterCredential = await resolveExecutionProviderStateIdentity(header, stores); + assert.notEqual(afterCredential, afterEndpoint); + + const headers = await stores.credentialVault.set({ + locator: { + scope: 'connection', + connectionId: connection.connectionId, + kind: 'request_headers', + }, + expected: null, + secret: JSON.stringify({ 'X-Relay-Account': 'account-b' }), + }); + assert.equal(headers.kind, 'committed'); + const afterHeaders = await resolveExecutionProviderStateIdentity(header, stores); + assert.notEqual(afterHeaders, afterCredential); + }); +}); + test('onboarding probes with the custom request headers the models path sends, and a header rotation supersedes', async () => { await withFixture(async ({ stores }) => { // A connection that authenticates through a custom header (plus a body diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index b808cc3928..74b2f472f1 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -162,6 +162,20 @@ test('backend creation resolves a bound Session by immutable Connection identity }); }); +test('backend creation rejects provider state drift after Run admission', async () => { + await assert.rejects( + createHostAiSdkBackend( + backendCreationFixture({ + abortSignal: new AbortController().signal, + providerStateIdentity: `sha256:${'f'.repeat(64)}`, + resolveExecutionConnection: async () => readyExecutionConnection(), + readPricing: async () => ({ revision: 0, overrides: [] }), + }), + ), + /Provider state changed after AgentRun admission/, + ); +}); + test('backend creation aborts a stalled canonical connection read', async () => { const abort = new AbortController(); const creating = createHostAiSdkBackend( @@ -3887,6 +3901,7 @@ async function publishConnectionModel( function backendCreationFixture(input: { abortSignal: AbortSignal; connectionId?: string; + providerStateIdentity?: `sha256:${string}`; resolveExecutionConnection: (ref?: unknown) => Promise; readPricing: () => Promise; runtimePolicy?: RuntimePolicyStoresWriter; @@ -3956,6 +3971,9 @@ function backendCreationFixture(input: { permissionMode: 'bypass', }, abortSignal: input.abortSignal, + ...(input.providerStateIdentity + ? { providerStateIdentity: input.providerStateIdentity } + : {}), ...(input.tools ? { tools: input.tools } : {}), ...(input.loadTurnRuntimeEvents ? { loadTurnRuntimeEvents: input.loadTurnRuntimeEvents } diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index c9f4cddd4c..1d3388a6a7 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -110,6 +110,7 @@ import { HostClientCapabilityCoordinator } from './client-capability-coordinator import { HostDeepResearchCoordinator } from './deep-research-coordinator.js'; import { HostDailyReviewCoordinator } from './daily-review-coordinator.js'; import { createHostAiSdkBackend } from './execution-model-composition.js'; +import { resolveExecutionProviderStateIdentity } from './execution-model-authority.js'; import { createInteractiveRunComposer, createInteractiveRunComposerFactory, @@ -981,6 +982,8 @@ export async function createExecutionRuntimeHostComposition( }, }), runBackendActivation: (operation) => runtimePolicyActivation.runBackendActivation(operation), + resolveProviderStateIdentity: (header) => + resolveExecutionProviderStateIdentity(header, runtimePolicyStores), messageAuthority: runtimeAuthority, hostedAgentGraphExecution: { readAgentGraphIntentClaim: (graphId, intentId) => diff --git a/packages/runtime-host/src/server/execution-model-authority.ts b/packages/runtime-host/src/server/execution-model-authority.ts index 9cc68188b3..b4d8ef7646 100644 --- a/packages/runtime-host/src/server/execution-model-authority.ts +++ b/packages/runtime-host/src/server/execution-model-authority.ts @@ -20,6 +20,7 @@ import { randomUUID } from 'node:crypto'; import { authorizeConnectionModel, + effectiveBaseUrl, PROVIDER_DEFAULTS, type RuntimeExecutionConnection, } from '@maka/core/llm-connections'; @@ -34,6 +35,7 @@ import { recordLlmCallStrict, } from '@maka/runtime/telemetry'; import { buildProviderOptions, getAIModel } from '@maka/runtime/model-factory'; +import { stableHash } from '@maka/runtime/request-shape'; import { buildSessionRecapMessages } from '@maka/runtime/session-recap'; import { buildSessionTitlePrompt, @@ -737,6 +739,60 @@ interface ResolvedExecutionTarget { readonly oauthBinding?: HostOAuthExecutionBinding; readonly networkProxy: RuntimePolicy['networkProxy']; readonly proxySecret?: string; + readonly providerStateIdentity: `sha256:${string}`; +} + +type ExecutionRouteHeader = Pick< + BackendFactoryContext['header'], + 'llmConnectionId' | 'llmConnectionSlug' | 'model' +>; + +function executionConnectionRef(header: ExecutionRouteHeader) { + return header.llmConnectionId === undefined + ? { kind: 'catalog_slug' as const, connectionSlug: header.llmConnectionSlug } + : { + kind: 'bound' as const, + connectionId: header.llmConnectionId, + connectionSlug: header.llmConnectionSlug, + }; +} + +function providerStateIdentityForResolvedExecution( + resolved: Extract< + Awaited>, + { kind: 'ready' } + >, +): `sha256:${string}` { + const credentialBasis = (material: typeof resolved.secretMaterial.connection) => + material ? { credentialId: material.credentialId, revision: material.revision } : null; + return stableHash({ + protocol: 'provider_state_identity_v1', + connectionId: resolved.connection.connectionId, + providerType: resolved.connection.providerType, + endpoint: new URL(effectiveBaseUrl(resolved.connection)).toString(), + credential: credentialBasis(resolved.secretMaterial.connection), + requestHeaders: credentialBasis(resolved.secretMaterial.requestHeaders), + }); +} + +export async function resolveExecutionProviderStateIdentity( + header: ExecutionRouteHeader, + runtimePolicy: { + readonly operations: Pick< + RuntimePolicyStoresWriter['operations'], + 'resolveExecutionConnection' + >; + }, +): Promise<`sha256:${string}`> { + const resolved = await runtimePolicy.operations.resolveExecutionConnection( + executionConnectionRef(header), + ); + if (resolved.kind !== 'ready') { + throw new AuxiliaryModelCallConfigurationError( + `Runtime Host model connection is not ready: ${resolved.kind}`, + ); + } + return providerStateIdentityForResolvedExecution(resolved); } async function resolveDailyReviewHeader( @@ -801,13 +857,7 @@ export async function resolveExecutionTarget( createFetchTransport: (proxy: ProxiedFetchProxy | null) => ProxiedFetchTransport, ): Promise { const resolved = await runtimePolicy.operations.resolveExecutionConnection( - header.llmConnectionId === undefined - ? { kind: 'catalog_slug', connectionSlug: header.llmConnectionSlug } - : { - kind: 'bound', - connectionId: header.llmConnectionId, - connectionSlug: header.llmConnectionSlug, - }, + executionConnectionRef(header), ); if (resolved.kind !== 'ready') { throw new AuxiliaryModelCallConfigurationError( @@ -852,6 +902,7 @@ export async function resolveExecutionTarget( const requestHeaders = resolved.secretMaterial.requestHeaders ? parseRequestHeaders(resolved.secretMaterial.requestHeaders.secret) : {}; + const providerStateIdentity = providerStateIdentityForResolvedExecution(resolved); if (provider.authKind === 'oauth_token') { const material = resolved.secretMaterial.connection; if (!material) { @@ -876,6 +927,7 @@ export async function resolveExecutionTarget( createRefreshTransport: () => createFetchTransport(refreshProxy), }), networkProxy: resolved.networkProxy, + providerStateIdentity, ...(resolved.secretMaterial.networkProxy ? { proxySecret: resolved.secretMaterial.networkProxy.secret } : {}), @@ -888,6 +940,7 @@ export async function resolveExecutionTarget( apiKey: resolved.secretMaterial.connection?.secret ?? '', requestHeaders, networkProxy: resolved.networkProxy, + providerStateIdentity, ...(resolved.secretMaterial.networkProxy ? { proxySecret: resolved.secretMaterial.networkProxy.secret } : {}), diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index 318823d4e1..7b182e1e40 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -115,6 +115,12 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom ), input.context.abortSignal, ); + if ( + input.context.providerStateIdentity !== undefined && + input.context.providerStateIdentity !== target.providerStateIdentity + ) { + throw new Error('Provider state changed after AgentRun admission'); + } const pricingSnapshot = await readDuringBackendCreation( () => input.usage.pricing.snapshot(), input.context.abortSignal, @@ -199,6 +205,7 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom buildOpenAiCodexHistoryCompactor({ resolveModel: resolveHistoryCompactModel, connectionId: input.context.header.llmConnectionId, + providerStateIdentity: target.providerStateIdentity, modelId: target.model, providerOptions, }), @@ -356,6 +363,7 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom } : {}), connection: target.connection, + providerStateIdentity: target.providerStateIdentity, apiKey, modelId: target.model, modelFactory, diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index b7f8ec93ec..2ba463e458 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -6709,6 +6709,59 @@ describe('AiSdkBackend model history', () => { assert.match(promptJson, /file contents/); }); + test('drops Anthropic reasoning after provider state changes under the same route id', async () => { + const model = completionModel(); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: { ...header(), llmConnectionId: 'connection-a', model: 'claude-a' }, + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'claude-a', + modelFactory: () => model, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + providerStateIdentity: `sha256:${'b'.repeat(64)}`, + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'continue', + context: [], + runtimeContextRunHeaders: [ + priorModelRunHeader({ + connectionId: 'connection-a', + modelId: 'claude-a', + providerStateIdentity: `sha256:${'a'.repeat(64)}`, + }), + ], + runtimeContext: [ + runtimeEvent({ + id: 'rt-thinking', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { kind: 'thinking', text: 'old account reasoning', signature: 'old-sig' }, + }), + runtimeTextEvent({ + id: 'rt-a', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + text: 'portable answer', + }), + ], + }), + ); + + const promptJson = JSON.stringify(compactPrompt(model)); + assert.equal(promptJson.includes('old account reasoning'), false); + assert.equal(promptJson.includes('old-sig'), false); + assert.match(promptJson, /portable answer/); + }); + test('fails closed for provider reasoning with no source run provenance', async () => { const model = completionModel(); const backend = createTestAiSdkBackend({ @@ -16029,6 +16082,7 @@ function priorModelRunHeader(input: { modelId: string; connectionSlug?: string; runId?: string; + providerStateIdentity?: `sha256:${string}`; }): AgentRunHeader { return { runId: input.runId ?? 'run-prev', @@ -16037,6 +16091,7 @@ function priorModelRunHeader(input: { status: 'completed', backendKind: 'ai-sdk', ...(input.connectionId ? { llmConnectionId: input.connectionId } : {}), + providerStateIdentity: input.providerStateIdentity ?? `sha256:${'1'.repeat(64)}`, llmConnectionSlug: input.connectionSlug ?? 'anthropic-main', modelId: input.modelId, cwd: '/tmp/maka', diff --git a/packages/runtime/src/__tests__/continuation-replay.test.ts b/packages/runtime/src/__tests__/continuation-replay.test.ts index 51ef7c5286..eaeff52100 100644 --- a/packages/runtime/src/__tests__/continuation-replay.test.ts +++ b/packages/runtime/src/__tests__/continuation-replay.test.ts @@ -31,6 +31,21 @@ import { import { PROVIDER_REPLAY_PROJECTION_VERSION } from '../model-history.js'; describe('continuation replay segment', () => { + it('rejects a persisted v1 admission under the route-bound v2 projection', () => { + const identity = runtimeIdentity(); + const result = buildContinuationReplaySegment({ + prefix: buildImmutableRuntimePrefix(identity, [ + { eventSeq: 1, event: textEvent('legacy-user', 'user', identity) }, + ]), + providerProjectionVersion: 1, + }); + + assert.equal(result.kind, 'blocked'); + if (result.kind !== 'blocked') return; + assert.equal(result.reason, 'provider_replay_unsupported'); + assert.match(result.diagnostics[0]?.message ?? '', /projection 1 is unsupported/); + }); + it('trims an interrupted assistant suffix after the last stable user boundary', () => { const identity = runtimeIdentity(); const result = buildContinuationReplaySegment({ @@ -328,7 +343,7 @@ describe('continuation replay segment', () => { providerProjectionVersion: PROVIDER_REPLAY_PROJECTION_VERSION, admissionRoute: { runHeaders: [], - targetConnectionId: undefined, + targetProviderStateIdentity: undefined, targetModelId: 'test-model', }, }); diff --git a/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts b/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts index a40de02125..7e454891fb 100644 --- a/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts +++ b/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts @@ -41,6 +41,7 @@ export function createTestAiSdkBackend(input: TestAiSdkBackendInput): AiSdkBacke let nextArtifactId = 0; return new AiSdkBackend({ readExecutionBoundary: readExternalExecutionBoundary, + providerStateIdentity: `sha256:${'1'.repeat(64)}`, ...backendInput, ...(testProjectionArtifacts ? { diff --git a/packages/runtime/src/__tests__/runtime-continuation.test.ts b/packages/runtime/src/__tests__/runtime-continuation.test.ts index e9e109c109..678a0f96fe 100644 --- a/packages/runtime/src/__tests__/runtime-continuation.test.ts +++ b/packages/runtime/src/__tests__/runtime-continuation.test.ts @@ -120,7 +120,7 @@ test('RuntimeContinuationPlanner reads the durable source boundary and allocates manifestDigest: plan.continuation?.boundary?.manifestDigest, }, providerReplayDigest: plan.continuation?.providerReplayDigest, - providerProjectionVersion: 1, + providerProjectionVersion: PROVIDER_REPLAY_PROJECTION_VERSION, safetySnapshot: { workspaceIdentity: 'workspace-1', backgroundOperationsSettled: true, @@ -884,7 +884,7 @@ function sameRouteAdmission() { runHeaders: ['run-1', 'run-2', 'run-3'].map((runId) => runHeader(runId, { llmConnectionId: 'connection-1' }), ), - targetConnectionId: 'connection-1', + targetProviderStateIdentity: undefined, targetModelId: 'test-model', }; } diff --git a/packages/runtime/src/__tests__/runtime-resume.test.ts b/packages/runtime/src/__tests__/runtime-resume.test.ts index a311e6624a..a751d8d894 100644 --- a/packages/runtime/src/__tests__/runtime-resume.test.ts +++ b/packages/runtime/src/__tests__/runtime-resume.test.ts @@ -734,7 +734,7 @@ function sameRouteAdmission() { runHeaders: ['run-1', 'run-2', 'run-3'].map((runId) => runHeader(runId, { llmConnectionId: 'connection-1' }), ), - targetConnectionId: 'connection-1', + targetProviderStateIdentity: undefined, targetModelId: 'test-model', }; } diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index bb18e7f9ff..ec8264abf7 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -5151,6 +5151,7 @@ describe('SessionManager permission mode updates', () => { const backends = new BackendRegistry(); const lifecycleEvents: Array<{ type: string }> = []; let backend: FinalTextTestBackend | undefined; + const providerStateIdentity = `sha256:${'c'.repeat(64)}` as const; backends.register('ai-sdk', (ctx) => { backend = new FinalTextTestBackend(ctx); return backend; @@ -5167,6 +5168,7 @@ describe('SessionManager permission mode updates', () => { backgroundOperationsSettled: true, availableToolNames: ['Read'], }), + resolveProviderStateIdentity: async () => providerStateIdentity, onContinuationLifecycleEvent: (event) => { lifecycleEvents.push(event); }, @@ -5189,6 +5191,7 @@ describe('SessionManager permission mode updates', () => { failureClass: 'runtime_interrupted', backendKind: header.backend, llmConnectionId: header.llmConnectionId, + providerStateIdentity, llmConnectionSlug: header.llmConnectionSlug, modelId: header.model, cwd: header.cwd, @@ -5312,6 +5315,7 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual(continuationRun.parentTurnId, sourceTurnId); assert.strictEqual(continuationRun.cwd, movedCwd); assert.strictEqual(continuationRun.status, 'completed'); + assert.strictEqual(continuationRun.providerStateIdentity, providerStateIdentity); assert.partialDeepStrictEqual(continuationRun, { orchestrationMode: 'swarm', orchestrationSource: 'turn_override', @@ -5324,13 +5328,15 @@ describe('SessionManager permission mode updates', () => { runId: runHeader.runId, llmConnectionId: runHeader.llmConnectionId, modelId: runHeader.modelId, + providerStateIdentity: runHeader.providerStateIdentity, })), [ - { - runId: sourceRunId, - llmConnectionId: header.llmConnectionId, - modelId: header.model, - }, + { + runId: sourceRunId, + llmConnectionId: header.llmConnectionId, + modelId: header.model, + providerStateIdentity, + }, ], ); const continuationEvents = await runStore.readRuntimeEvents( @@ -5341,14 +5347,7 @@ describe('SessionManager permission mode updates', () => { protocol: 'continuation_start_v2', claimId: plan.continuation.claimId, boundaryDigest: plan.continuation.boundary?.manifestDigest, - replayManifestDigest: plan.continuation.boundary?.manifestDigest, - providerProjectionVersion: 1, - providerReplayDigest: plan.continuation.providerReplayDigest, - }); - assert.deepStrictEqual( - (continuationEvents[0]?.actions?.continuationStart as Record | undefined) - ?.immediateSource, - { + immediateSource: { sessionId: session.id, invocationId: sourceInvocationId, runId: sourceRunId, @@ -5356,7 +5355,10 @@ describe('SessionManager permission mode updates', () => { highWater: sourceEvents.length, prefixDigest: plan.continuation.boundary?.segments.at(-1)?.prefixDigest, }, - ); + replayManifestDigest: plan.continuation.boundary?.manifestDigest, + providerProjectionVersion: 2, + providerReplayDigest: plan.continuation.providerReplayDigest, + }); assert.deepStrictEqual(continuationEvents[0]?.actions?.runtimeProtocol, { toolBoundary: 't1_after_preflight_v1', }); @@ -5386,6 +5388,10 @@ describe('SessionManager permission mode updates', () => { followUpContext.some((event) => event.runId === plan.continuation?.runId), true, ); + const followUpRun = (await runStore.listSessionRuns(session.id)).find( + (runHeader) => runHeader.turnId === 'turn-after-continuation', + ); + assert.strictEqual(followUpRun?.providerStateIdentity, providerStateIdentity); }); test('authenticates the exact target-aware continuation projection that reaches the provider', async () => { diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index 66e7e9236d..14f80879f6 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -150,6 +150,7 @@ export interface AgentRunInput { newId: () => string; now: () => number; workspaceIdentity?: string; + resolveProviderStateIdentity?: (header: SessionHeader) => Promise<`sha256:${string}` | undefined>; continuationFailpoint?: (point: RuntimeContinuationFailpoint) => Promise; /** Exact target header already committed inside the durable continuation claim. */ claimedRunHeader?: AgentRunHeader; @@ -247,6 +248,7 @@ export class AgentRun { private finalized = false; private terminalRunHeaderCommitted = false; private continuationActive = false; + private providerStateIdentity: `sha256:${string}` | undefined; private terminalClaim: | { owner: 'event' | 'stop'; @@ -324,6 +326,10 @@ export class AgentRun { return this.stopped; } + resolvedProviderStateIdentity(): `sha256:${string}` | undefined { + return this.providerStateIdentity; + } + isSessionInline(): boolean { return isSessionInlineRun({ ...(this.lineage.parentRunId ? { parentRunId: this.lineage.parentRunId } : {}), @@ -1052,6 +1058,10 @@ export class AgentRun { continuation && this.input.claimedRunHeader ? this.input.claimedRunHeader.createdAt : this.input.now(); + const providerStateIdentity = + this.input.claimedRunHeader?.providerStateIdentity ?? + (await this.input.resolveProviderStateIdentity?.(this.header)); + this.providerStateIdentity = providerStateIdentity; const computedHeader: AgentRunHeader = { runId: this.runId, invocationId: this.invocationId, @@ -1062,6 +1072,7 @@ export class AgentRun { ...(this.header.llmConnectionId === undefined ? {} : { llmConnectionId: this.header.llmConnectionId }), + ...(providerStateIdentity ? { providerStateIdentity } : {}), llmConnectionSlug: this.header.llmConnectionSlug, modelId: this.header.model, cwd: this.header.cwd, diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 600317547e..5540f6d1a0 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -696,6 +696,8 @@ export interface AiSdkBackendInput extends AiSdkCompactionCapabilities { // ── Session context ──────────────────────────────────────────────────── sessionId: string; header: SessionHeader; + /** Host-frozen provider endpoint and credential ownership for this backend generation. */ + providerStateIdentity?: `sha256:${string}`; /** Append-message function bound to this session (e.g. SessionStore wrapper). */ appendMessage: AppendMessageFn; /** Reads the authoritative session boundary immediately before every local tool invocation. */ @@ -1114,6 +1116,7 @@ export class AiSdkBackend implements AgentBackend { input, sessionId: this.sessionId, targetConnectionId: input.header.llmConnectionId, + targetProviderStateIdentity: input.providerStateIdentity, now: this.now, modelAdapter: this.modelAdapter, createProviderRequestTracker: (trackerInput) => @@ -1913,7 +1916,7 @@ export class AiSdkBackend implements AgentBackend { compatibleProviderReasoningReplayEventIds( replayEvents, input.runtimeContextRunHeaders, - this.input.header.llmConnectionId, + this.input.providerStateIdentity, this.input.modelId, scope.runId, ), @@ -3383,7 +3386,7 @@ export class AiSdkBackend implements AgentBackend { const providerReasoningReplayEventIds = compatibleProviderReasoningReplayEventIds( priorRuntimeContext, input.runtimeContextRunHeaders, - this.input.header.llmConnectionId, + this.input.providerStateIdentity, this.input.modelId, ); const projectedMessages = await this.materializePriorMessages( diff --git a/packages/runtime/src/ai-sdk-compaction.ts b/packages/runtime/src/ai-sdk-compaction.ts index 1f336082a3..07b903e944 100644 --- a/packages/runtime/src/ai-sdk-compaction.ts +++ b/packages/runtime/src/ai-sdk-compaction.ts @@ -155,6 +155,7 @@ export interface AiSdkCompactionDeps { input: AiSdkCompactionCapabilities; sessionId: string; targetConnectionId: string | undefined; + targetProviderStateIdentity: `sha256:${string}` | undefined; now: () => number; modelAdapter: ModelAdapter; /** @@ -187,6 +188,7 @@ export class AiSdkCompaction { private readonly input: AiSdkCompactionCapabilities; private readonly sessionId: string; private readonly targetConnectionId: string | undefined; + private readonly targetProviderStateIdentity: `sha256:${string}` | undefined; private readonly now: () => number; private readonly modelAdapter: ModelAdapter; private readonly createProviderRequestTracker: (input: { @@ -218,6 +220,7 @@ export class AiSdkCompaction { this.input = deps.input; this.sessionId = deps.sessionId; this.targetConnectionId = deps.targetConnectionId; + this.targetProviderStateIdentity = deps.targetProviderStateIdentity; this.now = deps.now; this.modelAdapter = deps.modelAdapter; this.createProviderRequestTracker = deps.createProviderRequestTracker; @@ -1080,7 +1083,7 @@ export class AiSdkCompaction { compatibleProviderReasoningReplayEventIds( plan.replacementEvents, state.priorRunHeaders, - this.targetConnectionId, + this.targetProviderStateIdentity, this.input.modelId, input.origin.runId, ), diff --git a/packages/runtime/src/continuation-replay.ts b/packages/runtime/src/continuation-replay.ts index 4f49f2d560..f9d6fc6e28 100644 --- a/packages/runtime/src/continuation-replay.ts +++ b/packages/runtime/src/continuation-replay.ts @@ -84,7 +84,7 @@ export type ContinuationReplayPlanResult = export interface ContinuationReplayAdmissionRoute { runHeaders: readonly AgentRunHeader[]; - targetConnectionId: string | undefined; + targetProviderStateIdentity: `sha256:${string}` | undefined; targetModelId: string; } @@ -113,7 +113,7 @@ export function buildContinuationReplayPlan(input: { const providerReasoningReplayEventIds = compatibleProviderReasoningReplayEventIds( runtimeContext, input.admissionRoute.runHeaders, - input.admissionRoute.targetConnectionId, + input.admissionRoute.targetProviderStateIdentity, input.admissionRoute.targetModelId, ); const providerItems = admitProviderReasoningReplayItems( @@ -128,7 +128,7 @@ export function buildContinuationReplayPlan(input: { boundary: createRuntimeBoundaryCursor(boundaries), providerReplayDigest: digestProviderReplayAdmission({ providerProjectionVersion: input.providerProjectionVersion, - targetConnectionId: input.admissionRoute.targetConnectionId, + targetProviderStateIdentity: input.admissionRoute.targetProviderStateIdentity, targetModelId: input.admissionRoute.targetModelId, items: providerItems, }), @@ -141,7 +141,7 @@ export function buildContinuationReplayPlan(input: { export function buildContinuationReplaySegment(input: { prefix: ImmutableRuntimePrefixV1; - providerProjectionVersion: typeof PROVIDER_REPLAY_PROJECTION_VERSION; + providerProjectionVersion: number; }): ContinuationReplaySegmentResult { const recovery = resolveRuntimeRecovery(input.prefix.events); if ( @@ -271,15 +271,15 @@ function isBlockingProjectionDiagnostic(diagnostic: RuntimeEventReplayDiagnostic export function digestProviderReplayAdmission(input: { providerProjectionVersion: number; - targetConnectionId: string | undefined; + targetProviderStateIdentity: `sha256:${string}` | undefined; targetModelId: string; items: readonly RuntimeEventModelReplayItem[]; }): RuntimeBoundaryDigest { const json = stableJsonStringify({ - protocol: 'provider_replay_admission_v1', + protocol: 'provider_replay_admission_v2', providerProjectionVersion: input.providerProjectionVersion, target: { - connectionId: input.targetConnectionId ?? null, + providerStateIdentity: input.targetProviderStateIdentity ?? null, modelId: input.targetModelId, }, items: input.items, diff --git a/packages/runtime/src/model-history.ts b/packages/runtime/src/model-history.ts index c0a708fb0e..b9938d91cc 100644 --- a/packages/runtime/src/model-history.ts +++ b/packages/runtime/src/model-history.ts @@ -77,7 +77,7 @@ import { import { estimateTokens, stableJsonLength, turnKey } from './context-budget-helpers.js'; import type { DurableToolResultProjection } from '@maka/core/durable-tool-result-projection'; -export const PROVIDER_REPLAY_PROJECTION_VERSION = 1; +export const PROVIDER_REPLAY_PROJECTION_VERSION = 2; /** * Resolve the RuntimeEvents whose provider-owned reasoning may cross the @@ -87,14 +87,17 @@ export const PROVIDER_REPLAY_PROJECTION_VERSION = 1; export function compatibleProviderReasoningReplayEventIds( events: readonly RuntimeEvent[], runHeaders: readonly AgentRunHeader[] | undefined, - targetConnectionId: string | undefined, + targetProviderStateIdentity: `sha256:${string}` | undefined, targetModelId: string, currentRunId?: string, ): ReadonlySet { const compatibleRunIds = new Set(currentRunId ? [currentRunId] : []); - if (targetConnectionId && runHeaders) { + if (targetProviderStateIdentity && runHeaders) { for (const run of runHeaders) { - if (run.llmConnectionId === targetConnectionId && run.modelId === targetModelId) { + if ( + run.providerStateIdentity === targetProviderStateIdentity && + run.modelId === targetModelId + ) { compatibleRunIds.add(run.runId); } } diff --git a/packages/runtime/src/openai-codex-history-compactor.ts b/packages/runtime/src/openai-codex-history-compactor.ts index 86eb7626f2..412e5e2f10 100644 --- a/packages/runtime/src/openai-codex-history-compactor.ts +++ b/packages/runtime/src/openai-codex-history-compactor.ts @@ -44,6 +44,7 @@ import { providerFailureDiagnostic } from './provider-error-classification.js'; export interface BuildOpenAiCodexHistoryCompactorOptions { resolveModel: () => unknown; connectionId: string; + providerStateIdentity?: `sha256:${string}`; modelId: string; providerOptions?: Record; } @@ -79,7 +80,7 @@ export function buildOpenAiCodexHistoryCompactor(options: BuildOpenAiCodexHistor const providerReasoningReplayEventIds = compatibleProviderReasoningReplayEventIds( events, input.source.runHeaders, - options.connectionId, + options.providerStateIdentity, options.modelId, input.runId, ); diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index 146dc3c986..d7c4babec8 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -269,6 +269,7 @@ export interface RuntimeKernelDeps { safeBoundaryResumeEnabled?: boolean; continuationFailpoint?: (point: RuntimeContinuationFailpoint) => Promise; runBackendActivation?: BackendActivationBoundary; + resolveProviderStateIdentity?: (header: SessionHeader) => Promise<`sha256:${string}` | undefined>; /** Hosted composition capability. When present, the Host owns all message queues. */ messageAuthority?: RuntimeMessageAuthority; /** Hosted composition capability. Omit for embedded interaction ownership. */ @@ -658,6 +659,7 @@ export class RuntimeKernel implements RuntimeKernelLike { repairRunRuntimeLedger: this.deps.repairRunRuntimeLedger, newId: this.deps.newId, now: this.deps.now, + resolveProviderStateIdentity: this.deps.resolveProviderStateIdentity, ...(workspaceIdentity ? { workspaceIdentity } : {}), hooks: { reserveRun: async (targetSessionId, nextHeader, activeRun) => { @@ -742,9 +744,10 @@ export class RuntimeKernel implements RuntimeKernelLike { this.deps.runStore.readRun(continuation.sessionId, continuation.sourceRunId), this.deps.runStore.listSessionRuns(continuation.sessionId), ]); + const targetProviderStateIdentity = await this.deps.resolveProviderStateIdentity?.(header); const admissionRoute: ContinuationReplayAdmissionRoute = { runHeaders: sessionRuns, - targetConnectionId: header.llmConnectionId, + targetProviderStateIdentity, targetModelId: header.model, }; const sourceEvents = await revalidateContinuationBoundary( @@ -770,6 +773,7 @@ export class RuntimeKernel implements RuntimeKernelLike { workspaceIdentity: continuation.safetySnapshot.workspaceIdentity, effectiveOrchestration, effectiveToolMode, + targetProviderStateIdentity, claimedAt, }); const claim = continuationClaimForExecution(continuation, claimedAt, targetRunHeader); @@ -819,6 +823,7 @@ export class RuntimeKernel implements RuntimeKernelLike { repairRunRuntimeLedger: this.deps.repairRunRuntimeLedger, newId: this.deps.newId, now: this.deps.now, + resolveProviderStateIdentity: this.deps.resolveProviderStateIdentity, workspaceIdentity: continuation.safetySnapshot.workspaceIdentity, effectiveOrchestration, claimedRunHeader: claim.targetRunHeader, @@ -972,6 +977,7 @@ export class RuntimeKernel implements RuntimeKernelLike { repairRunRuntimeLedger: this.deps.repairRunRuntimeLedger, newId: this.deps.newId, now: this.deps.now, + resolveProviderStateIdentity: this.deps.resolveProviderStateIdentity, effectiveOrchestration: resolveEffectiveOrchestration('default', undefined), hooks: { reserveRun: async (targetSessionId, nextHeader, activeRun) => { @@ -2298,6 +2304,7 @@ export class RuntimeKernel implements RuntimeKernelLike { header: SessionHeader, execution: PendingExecutionClaim, ): Promise { + const providerStateIdentity = execution.run?.resolvedProviderStateIdentity(); await this.clearBackendQuarantineForActivation(sessionId, execution); let existing = this.active.get(sessionId); if (existing) { @@ -2318,6 +2325,7 @@ export class RuntimeKernel implements RuntimeKernelLike { sessionId, workspaceRoot: header.workspaceRoot, header, + ...(providerStateIdentity ? { providerStateIdentity } : {}), store: this.deps.store, abortSignal: execution.abortController.signal, ...(subagent @@ -2794,6 +2802,7 @@ function continuationTargetRunHeaderForExecution(input: { workspaceIdentity: string; effectiveOrchestration: EffectiveOrchestration; effectiveToolMode: ToolMode; + targetProviderStateIdentity: `sha256:${string}` | undefined; claimedAt: number; }): AgentRunHeader { const { @@ -2821,6 +2830,9 @@ function continuationTargetRunHeaderForExecution(input: { ...(sessionHeader.llmConnectionId === undefined ? {} : { llmConnectionId: sessionHeader.llmConnectionId }), + ...(input.targetProviderStateIdentity + ? { providerStateIdentity: input.targetProviderStateIdentity } + : {}), llmConnectionSlug: sessionHeader.llmConnectionSlug, modelId: sessionHeader.model, cwd: sessionHeader.cwd, @@ -2909,7 +2921,7 @@ function consumeAdmittedRuntimeContinuation(input: { const providerReasoningReplayEventIds = compatibleProviderReasoningReplayEventIds( continuation.runtimeContext, input.admissionRoute.runHeaders, - input.admissionRoute.targetConnectionId, + input.admissionRoute.targetProviderStateIdentity, input.admissionRoute.targetModelId, ); const admittedItems = admitProviderReasoningReplayItems( @@ -2919,7 +2931,7 @@ function consumeAdmittedRuntimeContinuation(input: { if ( digestProviderReplayAdmission({ providerProjectionVersion: continuation.providerProjectionVersion, - targetConnectionId: input.admissionRoute.targetConnectionId, + targetProviderStateIdentity: input.admissionRoute.targetProviderStateIdentity, targetModelId: input.admissionRoute.targetModelId, items: admittedItems, }) !== continuation.providerReplayDigest diff --git a/packages/runtime/src/runtime-resume.ts b/packages/runtime/src/runtime-resume.ts index 6da22e1ae7..849972c74a 100644 --- a/packages/runtime/src/runtime-resume.ts +++ b/packages/runtime/src/runtime-resume.ts @@ -637,7 +637,7 @@ export class RuntimeContinuationPlanner { startKind: 'runtime_admission' | 'claim_repair'; claimId: string; boundaryDigest: RuntimeBoundaryDigest; - providerProjectionVersion: typeof PROVIDER_REPLAY_PROJECTION_VERSION; + providerProjectionVersion: 1 | typeof PROVIDER_REPLAY_PROJECTION_VERSION; providerReplayDigest: RuntimeBoundaryDigest; }> = []; let childRun = sourceRun; @@ -782,6 +782,12 @@ export class RuntimeContinuationPlanner { `durable continuation claim does not authenticate ${edge.childRunId}`, ); } + if (edge.providerProjectionVersion !== PROVIDER_REPLAY_PROJECTION_VERSION) { + throw new RuntimeLineageError( + 'runtime_lineage_replay_mismatch', + `continuation provider replay version is unsupported for ${edge.childRunId}`, + ); + } const edgeReplay = buildContinuationReplayPlan({ prefixes: segments.slice(0, childIndex) as [ ImmutableRuntimePrefixV1, @@ -790,7 +796,7 @@ export class RuntimeContinuationPlanner { providerProjectionVersion: edge.providerProjectionVersion, admissionRoute: { runHeaders, - targetConnectionId: state.claim.targetRunHeader.llmConnectionId, + targetProviderStateIdentity: state.claim.targetRunHeader.providerStateIdentity, targetModelId: state.claim.targetRunHeader.modelId, }, }); diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index bed7890ad5..6578aeea75 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -637,6 +637,8 @@ export interface BackendFactoryContext { sessionId: string; workspaceRoot: string; header: SessionHeader; + /** Provider state identity already frozen into the activating AgentRun. */ + providerStateIdentity?: `sha256:${string}`; store: SessionStore; /** Process-local cancellation for the execution that owns this activation. */ abortSignal?: AbortSignal; @@ -764,6 +766,7 @@ interface SessionManagerBaseDeps { inspectContinuationSafety?: (sessionId: string) => Promise; continuationFailpoint?: (point: RuntimeContinuationFailpoint) => Promise; runBackendActivation?: BackendActivationBoundary; + resolveProviderStateIdentity?: (header: SessionHeader) => Promise<`sha256:${string}` | undefined>; safeBoundaryResumeEnabled?: boolean; /** Hosted composition capability. Omit for the production embedded queue. */ messageAuthority?: RuntimeMessageAuthority; @@ -1987,9 +1990,10 @@ export class SessionManager { this.deps.store.readHeader(sessionId), this.deps.runStore.listSessionRuns(sessionId), ]); + const targetProviderStateIdentity = await this.deps.resolveProviderStateIdentity?.(header); admissionRoute = { runHeaders, - targetConnectionId: header.llmConnectionId, + targetProviderStateIdentity, targetModelId: header.model, }; } catch { From 47a121ccc571efe7b1dcd32f9a6ee8f2b9445477 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 21:15:24 +0800 Subject: [PATCH 13/16] fix(runtime): prepare provider target once per activation Generated-by: Codex --- .../connection-effect-coordinator.test.ts | 21 +- .../execution-model-composition.test.ts | 208 +++++++++--------- .../src/server/execution-composition.ts | 92 ++++---- .../src/server/execution-model-authority.ts | 22 +- .../src/server/execution-model-composition.ts | 57 ++++- .../computer-use-provider-protocol.test.ts | 9 + .../overflow-reactive-recovery.test.ts | 4 + .../src/__tests__/session-manager.test.ts | 18 +- packages/runtime/src/agent-run.ts | 17 +- packages/runtime/src/runtime-kernel.ts | 72 +++++- packages/runtime/src/session-manager.ts | 49 ++++- 11 files changed, 356 insertions(+), 213 deletions(-) diff --git a/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts b/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts index 6f04991bc0..e9f2f7d14d 100644 --- a/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts @@ -41,7 +41,7 @@ import { HostConnectionEffectCoordinator } from '../server/connection-effect-coo import { HostOAuthExecutionAuthority } from '../server/oauth-execution-authority.js'; import type { ConnectionContext } from '../server/operation-dispatcher.js'; import { RuntimePolicyActivationGate } from '../server/runtime-policy-activation-gate.js'; -import { resolveExecutionProviderStateIdentity } from '../server/execution-model-authority.js'; +import { resolveExecutionTarget } from '../server/execution-model-authority.js'; import type { ConnectionOnboardingSaveResult, OperationOutcome } from '../protocol/index.js'; const context: ConnectionContext = { @@ -503,7 +503,18 @@ test('provider state identity follows endpoint, credential, and request-header o llmConnectionSlug: connection.slug, model: 'gpt-5', }; - const initial = await resolveExecutionProviderStateIdentity(header, stores); + const resolveIdentity = async () => + ( + await resolveExecutionTarget( + header, + stores, + new HostOAuthExecutionAuthority(stores), + () => { + throw new Error('API-key provider must not create an OAuth refresh transport'); + }, + ) + ).providerStateIdentity; + const initial = await resolveIdentity(); const moved = await stores.connectionCatalog.update({ expected: { connectionId: connection.connectionId, revision: connection.revision }, @@ -515,7 +526,7 @@ test('provider state identity follows endpoint, credential, and request-header o }, }); assert.equal(moved.kind, 'committed'); - const afterEndpoint = await resolveExecutionProviderStateIdentity(header, stores); + const afterEndpoint = await resolveIdentity(); assert.notEqual(afterEndpoint, initial); const status = await connectionCredentialStatus(stores, connection); @@ -527,7 +538,7 @@ test('provider state identity follows endpoint, credential, and request-header o secret: 'key-b', }); assert.equal(rotated.kind, 'committed'); - const afterCredential = await resolveExecutionProviderStateIdentity(header, stores); + const afterCredential = await resolveIdentity(); assert.notEqual(afterCredential, afterEndpoint); const headers = await stores.credentialVault.set({ @@ -540,7 +551,7 @@ test('provider state identity follows endpoint, credential, and request-header o secret: JSON.stringify({ 'X-Relay-Account': 'account-b' }), }); assert.equal(headers.kind, 'committed'); - const afterHeaders = await resolveExecutionProviderStateIdentity(header, stores); + const afterHeaders = await resolveIdentity(); assert.notEqual(afterHeaders, afterCredential); }); }); diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 74b2f472f1..5d7b8eeaeb 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -92,6 +92,7 @@ import { } from '../server/execution-model-authority.js'; import { createHostAiSdkBackend, + prepareHostAiSdkBackend, resolveCollaborationPermissionMode, type HostAiSdkBackendInput, } from '../server/execution-model-composition.js'; @@ -162,18 +163,22 @@ test('backend creation resolves a bound Session by immutable Connection identity }); }); -test('backend creation rejects provider state drift after Run admission', async () => { - await assert.rejects( - createHostAiSdkBackend( - backendCreationFixture({ - abortSignal: new AbortController().signal, - providerStateIdentity: `sha256:${'f'.repeat(64)}`, - resolveExecutionConnection: async () => readyExecutionConnection(), - readPricing: async () => ({ revision: 0, overrides: [] }), - }), - ), - /Provider state changed after AgentRun admission/, - ); +test('prepared backend activation builds from its admitted provider snapshot', async () => { + let providerReadAvailable = true; + const input = backendCreationFixture({ + abortSignal: new AbortController().signal, + resolveExecutionConnection: async () => { + if (!providerReadAvailable) throw new Error('provider state was read after admission'); + return readyExecutionConnection(); + }, + readPricing: async () => ({ revision: 0, overrides: [] }), + }); + const { context, ...dependencies } = input; + const prepared = await prepareHostAiSdkBackend({ context, ...dependencies }); + providerReadAvailable = false; + + const backend = await prepared.build(context); + await backend.dispose(); }); test('backend creation aborts a stalled canonical connection read', async () => { @@ -830,95 +835,99 @@ test('Codex OAuth history compaction falls back to a text checkpoint after nativ resolve: async () => oauthTokens, }), } as unknown as HostOAuthExecutionAuthority; - const backend = await createHostAiSdkBackend( - backendCreationFixture({ - abortSignal: new AbortController().signal, - modelId, - oauthCredentials, - resolveExecutionConnection: async () => ({ - kind: 'ready', - connection: { - slug: 'backend-creation-connection', - providerType: 'openai-codex', - enabledModelIds: [modelId], - models: [ - { - id: modelId, - capabilities: { chat: true, functionCalling: true }, - contextWindow: 32_768, - maxOutputTokens: 1_024, - }, - ], - }, - networkProxy: { enabled: false }, - secretMaterial: { connection: { secret: 'oauth-material' } }, - }), - readPricing: async () => ({ revision: 0, overrides: [] }), - recordHistoryCompactCheckpoint: async (checkpoint) => { - recordedTextCheckpoint = 'summary' in checkpoint; - }, - recordModelCallAttempt: async ({ attempt }) => { - attempts.push(attempt); + const fixture = backendCreationFixture({ + abortSignal: new AbortController().signal, + modelId, + oauthCredentials, + resolveExecutionConnection: async () => ({ + kind: 'ready', + connection: { + slug: 'backend-creation-connection', + providerType: 'openai-codex', + enabledModelIds: [modelId], + models: [ + { + id: modelId, + capabilities: { chat: true, functionCalling: true }, + contextWindow: 32_768, + maxOutputTokens: 1_024, + }, + ], }, - createFetchTransport: () => ({ - fetch: async (url, init) => { - const body = JSON.parse(String(init?.body)) as Record; - requests.push({ - url: String(url), - body, - }); - const providerInput = Array.isArray(body.input) ? body.input : []; - if ( - !providerInput.some( - (item) => - typeof item === 'object' && - item !== null && - 'type' in item && - item.type === 'compaction_trigger', - ) - ) { - return Response.json({ - id: 'resp-text-fallback', - object: 'response', - created_at: 1, - status: 'completed', - model: modelId, - output: [ - { - type: 'message', - id: 'msg-text-fallback', - status: 'completed', - role: 'assistant', - content: [ - { - type: 'output_text', - text: fallbackSummary, - annotations: [], - logprobs: [], - }, - ], - }, - ], - usage: { input_tokens: 4_000, output_tokens: 60, total_tokens: 4_060 }, - }); - } - return Response.json( - { - error: { - message: 'request rejected without echoing this body', - code: 'missing_required_parameter', + networkProxy: { enabled: false }, + secretMaterial: { connection: { secret: 'oauth-material' } }, + }), + readPricing: async () => ({ revision: 0, overrides: [] }), + recordHistoryCompactCheckpoint: async (checkpoint) => { + recordedTextCheckpoint = 'summary' in checkpoint; + }, + recordModelCallAttempt: async ({ attempt }) => { + attempts.push(attempt); + }, + createFetchTransport: () => ({ + fetch: async (url, init) => { + const body = JSON.parse(String(init?.body)) as Record; + requests.push({ + url: String(url), + body, + }); + const providerInput = Array.isArray(body.input) ? body.input : []; + if ( + !providerInput.some( + (item) => + typeof item === 'object' && + item !== null && + 'type' in item && + item.type === 'compaction_trigger', + ) + ) { + return Response.json({ + id: 'resp-text-fallback', + object: 'response', + created_at: 1, + status: 'completed', + model: modelId, + output: [ + { + type: 'message', + id: 'msg-text-fallback', + status: 'completed', + role: 'assistant', + content: [ + { + type: 'output_text', + text: fallbackSummary, + annotations: [], + logprobs: [], + }, + ], }, + ], + usage: { input_tokens: 4_000, output_tokens: 60, total_tokens: 4_060 }, + }); + } + return Response.json( + { + error: { + message: 'request rejected without echoing this body', + code: 'missing_required_parameter', }, - { - status: 400, - headers: { 'x-request-id': 'req-codex-compact' }, - }, - ); - }, - close: async () => undefined, - }), + }, + { + status: 400, + headers: { 'x-request-id': 'req-codex-compact' }, + }, + ); + }, + close: async () => undefined, }), - ); + }); + const { context, ...dependencies } = fixture; + const prepared = await prepareHostAiSdkBackend({ context, ...dependencies }); + const providerStateIdentity = prepared.providerStateIdentity; + assert.ok(providerStateIdentity); + const backend = await prepared.build(context); + assert.ok(backend.compactHistory); try { const runtimeContext: RuntimeEvent[] = [ @@ -1054,6 +1063,7 @@ test('Codex OAuth history compaction falls back to a text checkpoint after nativ llmConnectionId: '11111111-1111-4111-8111-111111111111', llmConnectionSlug: 'backend-creation-connection', modelId, + providerStateIdentity, cwd: '/workspace', permissionMode: 'bypass', createdAt: 2, @@ -3901,7 +3911,6 @@ async function publishConnectionModel( function backendCreationFixture(input: { abortSignal: AbortSignal; connectionId?: string; - providerStateIdentity?: `sha256:${string}`; resolveExecutionConnection: (ref?: unknown) => Promise; readPricing: () => Promise; runtimePolicy?: RuntimePolicyStoresWriter; @@ -3971,9 +3980,6 @@ function backendCreationFixture(input: { permissionMode: 'bypass', }, abortSignal: input.abortSignal, - ...(input.providerStateIdentity - ? { providerStateIdentity: input.providerStateIdentity } - : {}), ...(input.tools ? { tools: input.tools } : {}), ...(input.loadTurnRuntimeEvents ? { loadTurnRuntimeEvents: input.loadTurnRuntimeEvents } diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 1d3388a6a7..ee36b4ac2a 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -41,6 +41,7 @@ import { BackendRegistry, SessionManager, type BackendFactory, + type BackendPreparationContext, } from '@maka/runtime/session-manager'; import { buildToolsForAgentDefinition } from '@maka/runtime/agent-catalog'; import { buildHistoryTools } from '@maka/runtime/history-tools'; @@ -109,8 +110,7 @@ import { HostContextCoordinator } from './context-coordinator.js'; import { HostClientCapabilityCoordinator } from './client-capability-coordinator.js'; import { HostDeepResearchCoordinator } from './deep-research-coordinator.js'; import { HostDailyReviewCoordinator } from './daily-review-coordinator.js'; -import { createHostAiSdkBackend } from './execution-model-composition.js'; -import { resolveExecutionProviderStateIdentity } from './execution-model-authority.js'; +import { prepareHostAiSdkBackend } from './execution-model-composition.js'; import { createInteractiveRunComposer, createInteractiveRunComposerFactory, @@ -665,51 +665,51 @@ export async function createExecutionRuntimeHostComposition( lane: memoryExtractionLane, acquireResidency: () => context.acquireResidency('memory-extraction'), }); + const hostAiSdkBackendInput = (backendContext: T) => ({ + context: backendContext, + runtimePolicy: runtimePolicyStores, + oauthCredentials, + createRunComposer: createInteractiveRunComposerFactory({ + skills, + memory: requireMemory(memory), + taskLedger, + clientCapabilities: requireClientCapabilities(clientCapabilities), + resolveTavilyWebSearchReadiness: () => + resolveHostTavilyWebSearchReadiness(runtimePolicyStores.operations), + ...(scheduledTaskTool ? { scheduledTaskTool } : {}), + planStore: openedPlanStore, + deepResearchTools: requireDeepResearch(deepResearch).toolsForSession( + backendContext.sessionId, + ), + goalTools: requireGoal(goal).tools, + builtinTools, + hostTools, + resolveRootTools: (sessionId) => + requireGraphCoordinator(graphCoordinator).toolsForSession(sessionId), + parentAgentTools: childAgentTools.parentTools, + childTools: childAgentTools.childTools, + worktreePatchWriteBackAvailable: true, + }), + ...(hostedExecutionRunProfile(backendContext.header.toolProfile)?.memoryExtraction === false + ? {} + : { memoryExtraction }), + artifacts: openedArtifactStore, + ...(openedContextOffloadReader ? { contextOffload: openedContextOffloadReader } : {}), + ...(storage.contextOffloadUnavailable ? { contextOffloadUnavailable: true } : {}), + executionArtifacts, + usage: openedUsageStores, + childAgents: bindHostChildAgentBackend( + requireSessionManager(manager), + backendContext.sessionId, + ), + runtimeCommitSink: stores.runtimeEventStore, + requestDrain: context.requestDrain, + }); backends.register( 'ai-sdk', - dependencies.primaryBackendFactory ?? - ((backendContext) => - createHostAiSdkBackend({ - context: backendContext, - runtimePolicy: runtimePolicyStores, - oauthCredentials, - createRunComposer: createInteractiveRunComposerFactory({ - skills, - memory: requireMemory(memory), - taskLedger, - clientCapabilities: requireClientCapabilities(clientCapabilities), - resolveTavilyWebSearchReadiness: () => - resolveHostTavilyWebSearchReadiness(runtimePolicyStores.operations), - ...(scheduledTaskTool ? { scheduledTaskTool } : {}), - planStore: openedPlanStore, - deepResearchTools: requireDeepResearch(deepResearch).toolsForSession( - backendContext.sessionId, - ), - goalTools: requireGoal(goal).tools, - builtinTools, - hostTools, - resolveRootTools: (sessionId) => - requireGraphCoordinator(graphCoordinator).toolsForSession(sessionId), - parentAgentTools: childAgentTools.parentTools, - childTools: childAgentTools.childTools, - worktreePatchWriteBackAvailable: true, - }), - ...(hostedExecutionRunProfile(backendContext.header.toolProfile)?.memoryExtraction === - false - ? {} - : { memoryExtraction }), - artifacts: openedArtifactStore, - ...(openedContextOffloadReader ? { contextOffload: openedContextOffloadReader } : {}), - ...(storage.contextOffloadUnavailable ? { contextOffloadUnavailable: true } : {}), - executionArtifacts, - usage: openedUsageStores, - childAgents: bindHostChildAgentBackend( - requireSessionManager(manager), - backendContext.sessionId, - ), - runtimeCommitSink: stores.runtimeEventStore, - requestDrain: context.requestDrain, - })), + dependencies.primaryBackendFactory ?? { + prepare: (backendContext) => prepareHostAiSdkBackend(hostAiSdkBackendInput(backendContext)), + }, ); const runtimeAuthority: RuntimeHostedRootAuthority = { bindRun: (identity) => messages.bindRun(identity), @@ -982,8 +982,6 @@ export async function createExecutionRuntimeHostComposition( }, }), runBackendActivation: (operation) => runtimePolicyActivation.runBackendActivation(operation), - resolveProviderStateIdentity: (header) => - resolveExecutionProviderStateIdentity(header, runtimePolicyStores), messageAuthority: runtimeAuthority, hostedAgentGraphExecution: { readAgentGraphIntentClaim: (graphId, intentId) => diff --git a/packages/runtime-host/src/server/execution-model-authority.ts b/packages/runtime-host/src/server/execution-model-authority.ts index b4d8ef7646..5003be4e2c 100644 --- a/packages/runtime-host/src/server/execution-model-authority.ts +++ b/packages/runtime-host/src/server/execution-model-authority.ts @@ -731,7 +731,7 @@ class AuxiliaryModelCallConfigurationError extends Error { } } -interface ResolvedExecutionTarget { +export interface ResolvedExecutionTarget { readonly connection: RuntimeExecutionConnection; readonly model: string; readonly apiKey: string; @@ -775,26 +775,6 @@ function providerStateIdentityForResolvedExecution( }); } -export async function resolveExecutionProviderStateIdentity( - header: ExecutionRouteHeader, - runtimePolicy: { - readonly operations: Pick< - RuntimePolicyStoresWriter['operations'], - 'resolveExecutionConnection' - >; - }, -): Promise<`sha256:${string}`> { - const resolved = await runtimePolicy.operations.resolveExecutionConnection( - executionConnectionRef(header), - ); - if (resolved.kind !== 'ready') { - throw new AuxiliaryModelCallConfigurationError( - `Runtime Host model connection is not ready: ${resolved.kind}`, - ); - } - return providerStateIdentityForResolvedExecution(resolved); -} - async function resolveDailyReviewHeader( runtimePolicy: RuntimePolicyStoresWriter, modelKey: string, diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index 7b182e1e40..6f83065f1f 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -43,7 +43,11 @@ import { } from '@maka/runtime/network/scoped-fetch-transport'; import { stableHash, toolCatalogHash } from '@maka/runtime/request-shape'; import { toolAvailabilityHash } from '@maka/runtime/tool-availability'; -import { type BackendFactoryContext } from '@maka/runtime/session-manager'; +import { + type BackendFactoryContext, + type BackendPreparationContext, + type PreparedBackendActivation, +} from '@maka/runtime/session-manager'; import { type RuntimeCommitSink } from '@maka/runtime/runtime-commit-sink'; import { createAttachmentByteReader, @@ -62,7 +66,11 @@ import { import type { HostChildAgentBackendCapabilities } from './child-agent-composition.js'; import type { HostExecutionArtifactServices } from './execution-artifacts.js'; import type { HostMemoryExtractionCoordinator } from './memory-extraction-coordinator.js'; -import { readDuringBackendCreation, resolveExecutionTarget } from './execution-model-authority.js'; +import { + readDuringBackendCreation, + resolveExecutionTarget, + type ResolvedExecutionTarget, +} from './execution-model-authority.js'; import { toRuntimePolicyProxy } from './runtime-policy-proxy.js'; import type { HostRunComposer, HostRunComposerFactory } from './host-run-composer.js'; @@ -83,6 +91,10 @@ export interface HostAiSdkBackendInput { readonly createFetchTransport?: (proxy: ProxiedFetchProxy | null) => ProxiedFetchTransport; } +export type HostAiSdkBackendPreparationInput = Omit & { + readonly context: BackendPreparationContext; +}; + type HostExecutionRuntimePolicyAuthority = { readonly operations: Pick; readonly runtimePolicy: Pick; @@ -115,12 +127,14 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom ), input.context.abortSignal, ); - if ( - input.context.providerStateIdentity !== undefined && - input.context.providerStateIdentity !== target.providerStateIdentity - ) { - throw new Error('Provider state changed after AgentRun admission'); - } + return await buildHostAiSdkBackend(input, target); +} + +async function buildHostAiSdkBackend( + input: HostAiSdkBackendInput, + target: ResolvedExecutionTarget, +): Promise { + const createFetchTransport = input.createFetchTransport ?? createProxiedFetchTransport; const pricingSnapshot = await readDuringBackendCreation( () => input.usage.pricing.snapshot(), input.context.abortSignal, @@ -466,6 +480,33 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom } } +export async function prepareHostAiSdkBackend( + input: HostAiSdkBackendPreparationInput, +): Promise { + const createFetchTransport = input.createFetchTransport ?? createProxiedFetchTransport; + const preparedTarget = await readDuringBackendCreation( + () => + resolveExecutionTarget( + input.context.header, + input.runtimePolicy, + input.oauthCredentials, + createFetchTransport, + ), + input.context.abortSignal, + ); + return { + providerStateIdentity: preparedTarget.providerStateIdentity, + build: (context) => + buildHostAiSdkBackend( + { + ...input, + context, + }, + preparedTarget, + ), + }; +} + class HostAiSdkBackend extends AiSdkBackend { constructor( input: ConstructorParameters[0], diff --git a/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts b/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts index e5ed84979f..70ae04705d 100644 --- a/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts +++ b/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts @@ -44,6 +44,7 @@ import { createTestAiSdkBackend } from './execution-boundary-test-helpers.js'; import { latestObservationIn } from './observation-text-reader.js'; const servers: Array<{ close(): Promise }> = []; +const PROVIDER_STATE_IDENTITY = `sha256:${'1'.repeat(64)}` as const; after(async () => { await Promise.all(servers.map((server) => server.close())); @@ -94,6 +95,7 @@ describe('Anthropic-compatible Computer Use product loops', () => { appendMessage: async () => {}, connection: providerConnection, apiKey: 'test-key', + providerStateIdentity: PROVIDER_STATE_IDENTITY, modelId: 'claude-sonnet-4-5-20250929', modelFactory: (input) => getAIModel(input), tools: [], @@ -112,6 +114,7 @@ describe('Anthropic-compatible Computer Use product loops', () => { llmConnectionId: 'connection-anthropic', llmConnectionSlug: 'anthropic', modelId: 'claude-sonnet-4-5-20250929', + providerStateIdentity: PROVIDER_STATE_IDENTITY, cwd: '/tmp/maka', permissionMode: 'bypass', createdAt: 1, @@ -475,6 +478,7 @@ describe('OpenAI-compatible product loops', () => { appendMessage: async () => {}, connection: providerConnection, apiKey: 'test-key', + providerStateIdentity: PROVIDER_STATE_IDENTITY, modelId: 'gpt-5.4', modelFactory: (input) => getAIModel(input), tools: [], @@ -492,6 +496,7 @@ describe('OpenAI-compatible product loops', () => { llmConnectionId: 'connection-copilot', llmConnectionSlug: 'github-copilot', modelId: 'gpt-5.4', + providerStateIdentity: PROVIDER_STATE_IDENTITY, cwd: '/tmp/maka', permissionMode: 'bypass', createdAt: 1, @@ -684,6 +689,7 @@ describe('OpenAI-compatible product loops', () => { llmConnectionId: 'test-connection-id', llmConnectionSlug: providerConnection.slug, modelId: 'k3', + providerStateIdentity: PROVIDER_STATE_IDENTITY, cwd: '/tmp/maka', permissionMode: 'ask', createdAt: 1, @@ -741,6 +747,7 @@ describe('OpenAI-compatible product loops', () => { appendMessage: async () => {}, connection: providerConnection, apiKey: 'test-key', + providerStateIdentity: PROVIDER_STATE_IDENTITY, modelId: 'k3', modelFactory: (input) => getAIModel(input), providerOptions: buildProviderOptions(providerConnection, 'k3'), @@ -829,6 +836,7 @@ describe('OpenAI-compatible product loops', () => { llmConnectionId: 'test-connection-id', llmConnectionSlug: providerConnection.slug, modelId: 'k3', + providerStateIdentity: PROVIDER_STATE_IDENTITY, cwd: '/tmp/maka', permissionMode: 'ask', createdAt: firstTurn.anchor.ts, @@ -845,6 +853,7 @@ describe('OpenAI-compatible product loops', () => { }, connection: providerConnection, apiKey: 'test-key', + providerStateIdentity: PROVIDER_STATE_IDENTITY, modelId: 'k3', modelFactory: (input) => getAIModel(input), providerOptions: buildProviderOptions(providerConnection, 'k3'), diff --git a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts index 02a150ff41..f1c2ebc488 100644 --- a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts +++ b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts @@ -62,6 +62,7 @@ function reactiveStructuredSummary(foldedRuntimeEvents: RuntimeEvent[]): string const RAW_SPAN_ONE = 'RAW_SPAN_ONE_'.repeat(24); const ANCHOR_TEXT = 'reactive overflow recovery keep my exact words'; const OVERFLOW_MESSAGE = 'prompt is too long: 213462 tokens > 200000 maximum'; +const PROVIDER_STATE_IDENTITY = `sha256:${'1'.repeat(64)}` as const; /** * Per-provider-request script. Each entry drives one `doStream` invocation: @@ -584,6 +585,7 @@ function buildReactiveFixture(options: ReactiveFixtureOptions): ReactiveFixture models: [{ id: 'mock-model-id', contextWindow }], }, apiKey: 'sk-test', + ...(options.reasoningReplayTail ? { providerStateIdentity: PROVIDER_STATE_IDENTITY } : {}), modelId: 'mock-model-id', modelFactory: () => model, ...(options.imagePrior || options.currentImage || options.liveImageResult @@ -1912,6 +1914,8 @@ function priorRunHeader(runId: string, llmConnectionId: string, modelId: string) llmConnectionId, llmConnectionSlug: 'anthropic-source', modelId, + providerStateIdentity: + runId === 'same-route-prior-run' ? PROVIDER_STATE_IDENTITY : `sha256:${'2'.repeat(64)}`, cwd: '/tmp/maka', permissionMode: 'ask', createdAt: 1, diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index ec8264abf7..6c62b731de 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -4645,6 +4645,9 @@ describe('SessionManager permission mode updates', () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); + backends.register('ai-sdk', () => { + throw new Error('continuation planning must not build a backend'); + }); const manager = new SessionManager({ store, runStore, @@ -4787,6 +4790,9 @@ describe('SessionManager permission mode updates', () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); + backends.register('ai-sdk', () => { + throw new Error('continuation planning must not build a backend'); + }); const manager = new SessionManager({ store, runStore, @@ -5152,9 +5158,14 @@ describe('SessionManager permission mode updates', () => { const lifecycleEvents: Array<{ type: string }> = []; let backend: FinalTextTestBackend | undefined; const providerStateIdentity = `sha256:${'c'.repeat(64)}` as const; - backends.register('ai-sdk', (ctx) => { - backend = new FinalTextTestBackend(ctx); - return backend; + backends.register('ai-sdk', { + prepare: async () => ({ + providerStateIdentity, + build: (ctx) => { + backend = new FinalTextTestBackend(ctx); + return backend; + }, + }), }); const manager = new SessionManager({ store, @@ -5168,7 +5179,6 @@ describe('SessionManager permission mode updates', () => { backgroundOperationsSettled: true, availableToolNames: ['Read'], }), - resolveProviderStateIdentity: async () => providerStateIdentity, onContinuationLifecycleEvent: (event) => { lifecycleEvents.push(event); }, diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index 14f80879f6..b483bb90c3 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -150,7 +150,6 @@ export interface AgentRunInput { newId: () => string; now: () => number; workspaceIdentity?: string; - resolveProviderStateIdentity?: (header: SessionHeader) => Promise<`sha256:${string}` | undefined>; continuationFailpoint?: (point: RuntimeContinuationFailpoint) => Promise; /** Exact target header already committed inside the durable continuation claim. */ claimedRunHeader?: AgentRunHeader; @@ -326,8 +325,17 @@ export class AgentRun { return this.stopped; } - resolvedProviderStateIdentity(): `sha256:${string}` | undefined { - return this.providerStateIdentity; + headerSnapshot(): SessionHeader { + return this.header; + } + + bindProviderStateIdentity(identity: `sha256:${string}` | undefined): void { + const claimed = this.input.claimedRunHeader?.providerStateIdentity; + const expected = claimed ?? this.providerStateIdentity; + if (expected !== undefined && expected !== identity) { + throw new Error('Prepared backend provider state does not match the AgentRun admission'); + } + this.providerStateIdentity = identity; } isSessionInline(): boolean { @@ -1059,8 +1067,7 @@ export class AgentRun { ? this.input.claimedRunHeader.createdAt : this.input.now(); const providerStateIdentity = - this.input.claimedRunHeader?.providerStateIdentity ?? - (await this.input.resolveProviderStateIdentity?.(this.header)); + this.input.claimedRunHeader?.providerStateIdentity ?? this.providerStateIdentity; this.providerStateIdentity = providerStateIdentity; const computedHeader: AgentRunHeader = { runId: this.runId, diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index d7c4babec8..2a285ef792 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -85,6 +85,7 @@ import type { BackendFactoryContext, BackendRegistry, CompactSessionInput, + PreparedBackendActivation, ResolvedChildToolActivation, SessionStore, StopSessionInput, @@ -269,7 +270,6 @@ export interface RuntimeKernelDeps { safeBoundaryResumeEnabled?: boolean; continuationFailpoint?: (point: RuntimeContinuationFailpoint) => Promise; runBackendActivation?: BackendActivationBoundary; - resolveProviderStateIdentity?: (header: SessionHeader) => Promise<`sha256:${string}` | undefined>; /** Hosted composition capability. When present, the Host owns all message queues. */ messageAuthority?: RuntimeMessageAuthority; /** Hosted composition capability. Omit for embedded interaction ownership. */ @@ -283,6 +283,7 @@ interface BackendGeneration extends AgentRunActiveSession { generation: number; phase: 'active' | 'stopping' | 'disposing' | 'failed' | 'terminated'; backend: AgentBackend; + providerStateIdentity?: `sha256:${string}`; stopBackend: AgentBackend['stop']; stopState: | { kind: 'idle' } @@ -349,6 +350,7 @@ interface PendingExecutionClaim { rejectSettled(error: unknown): void; phase: 'pending' | 'attached' | 'reserved' | 'released' | 'failed'; run?: AgentRun; + backendPreparation?: PreparedBackendActivation; stopIntent?: SessionStopIntent; finalization?: ExecutionClaimOutcome; } @@ -659,7 +661,6 @@ export class RuntimeKernel implements RuntimeKernelLike { repairRunRuntimeLedger: this.deps.repairRunRuntimeLedger, newId: this.deps.newId, now: this.deps.now, - resolveProviderStateIdentity: this.deps.resolveProviderStateIdentity, ...(workspaceIdentity ? { workspaceIdentity } : {}), hooks: { reserveRun: async (targetSessionId, nextHeader, activeRun) => { @@ -744,7 +745,14 @@ export class RuntimeKernel implements RuntimeKernelLike { this.deps.runStore.readRun(continuation.sessionId, continuation.sourceRunId), this.deps.runStore.listSessionRuns(continuation.sessionId), ]); - const targetProviderStateIdentity = await this.deps.resolveProviderStateIdentity?.(header); + const targetProviderStateIdentity = ( + await this.deps.backends.prepare(header.backend, { + sessionId: continuation.sessionId, + workspaceRoot: header.workspaceRoot, + header, + abortSignal: execution.abortController.signal, + }) + ).providerStateIdentity; const admissionRoute: ContinuationReplayAdmissionRoute = { runHeaders: sessionRuns, targetProviderStateIdentity, @@ -823,7 +831,6 @@ export class RuntimeKernel implements RuntimeKernelLike { repairRunRuntimeLedger: this.deps.repairRunRuntimeLedger, newId: this.deps.newId, now: this.deps.now, - resolveProviderStateIdentity: this.deps.resolveProviderStateIdentity, workspaceIdentity: continuation.safetySnapshot.workspaceIdentity, effectiveOrchestration, claimedRunHeader: claim.targetRunHeader, @@ -977,7 +984,6 @@ export class RuntimeKernel implements RuntimeKernelLike { repairRunRuntimeLedger: this.deps.repairRunRuntimeLedger, newId: this.deps.newId, now: this.deps.now, - resolveProviderStateIdentity: this.deps.resolveProviderStateIdentity, effectiveOrchestration: resolveEffectiveOrchestration('default', undefined), hooks: { reserveRun: async (targetSessionId, nextHeader, activeRun) => { @@ -1010,7 +1016,12 @@ export class RuntimeKernel implements RuntimeKernelLike { runId: run.runId, }); } - begin = await this.runBackendActivation(() => run.beginOperation()); + begin = await this.runBackendActivation(async () => { + run.bindProviderStateIdentity( + await this.prepareBackendForExecution(sessionId, header, execution), + ); + return await run.beginOperation(); + }); await input.hostedRoot?.onRunStarted?.(); this.settleReservedExecutionClaim(execution, run, { ok: true }); } catch (error) { @@ -1137,6 +1148,9 @@ export class RuntimeKernel implements RuntimeKernelLike { } begin = await this.runBackendActivation(async () => { await prepareBackendActivation?.(); + run.bindProviderStateIdentity( + await this.prepareBackendForExecution(sessionId, run.headerSnapshot(), execution), + ); const started = await run.begin(); await owners.bindInteraction(this.deps.interactionAuthority, { sessionId, @@ -1289,6 +1303,13 @@ export class RuntimeKernel implements RuntimeKernelLike { throw new Error('Durable continuation omitted final safety revalidation'); } await revalidateSafety(); + run.bindProviderStateIdentity( + await this.prepareBackendForExecution( + continuation.sessionId, + run.headerSnapshot(), + execution, + ), + ); const started = await run.beginContinuation(continuation); await owners.bindInteraction(this.deps.interactionAuthority, { sessionId: continuation.sessionId, @@ -2299,12 +2320,28 @@ export class RuntimeKernel implements RuntimeKernelLike { }; } + private async prepareBackendForExecution( + sessionId: string, + header: SessionHeader, + execution: PendingExecutionClaim, + ): Promise<`sha256:${string}` | undefined> { + const existing = this.active.get(sessionId); + if (existing) return existing.providerStateIdentity; + const prepared = await this.deps.backends.prepare(header.backend, { + sessionId, + workspaceRoot: header.workspaceRoot, + header, + abortSignal: execution.abortController.signal, + }); + execution.backendPreparation = prepared; + return prepared.providerStateIdentity; + } + private async ensureActive( sessionId: string, header: SessionHeader, execution: PendingExecutionClaim, ): Promise { - const providerStateIdentity = execution.run?.resolvedProviderStateIdentity(); await this.clearBackendQuarantineForActivation(sessionId, execution); let existing = this.active.get(sessionId); if (existing) { @@ -2320,12 +2357,20 @@ export class RuntimeKernel implements RuntimeKernelLike { const entry = await this.shareBackendActivation(`parent:${sessionId}`, async () => { const current = this.active.get(sessionId); if (current) return current; + const prepared = + execution.backendPreparation ?? + (await this.deps.backends.prepare(header.backend, { + sessionId, + workspaceRoot: header.workspaceRoot, + header, + abortSignal: execution.abortController.signal, + })); + execution.run?.bindProviderStateIdentity(prepared.providerStateIdentity); const subagent = await this.resolveSubagentActivation(header); - const backend = await this.deps.backends.build(header.backend, { + const backend = await prepared.build({ sessionId, workspaceRoot: header.workspaceRoot, header, - ...(providerStateIdentity ? { providerStateIdentity } : {}), store: this.deps.store, abortSignal: execution.abortController.signal, ...(subagent @@ -2341,7 +2386,12 @@ export class RuntimeKernel implements RuntimeKernelLike { allowMidTurnHistoryCompaction: Boolean(this.deps.runtimeEventStore), }); await this.rejectCancelledBackendActivation(backend, header, execution); - const generation = this.createBackendGeneration(sessionId, backend, header); + const generation = this.createBackendGeneration( + sessionId, + backend, + header, + prepared.providerStateIdentity, + ); this.active.set(sessionId, generation); return generation; }); @@ -2417,12 +2467,14 @@ export class RuntimeKernel implements RuntimeKernelLike { sessionId: string, backend: AgentBackend, header: SessionHeader, + providerStateIdentity?: `sha256:${string}`, ): BackendGeneration { const active: BackendGeneration = { sessionId, generation: ++this.nextBackendGeneration, phase: 'active', backend, + ...(providerStateIdentity ? { providerStateIdentity } : {}), stopBackend: undefined as never, stopState: { kind: 'idle' }, cachedHeader: header, diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 6578aeea75..ddb8c59ea9 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -637,8 +637,6 @@ export interface BackendFactoryContext { sessionId: string; workspaceRoot: string; header: SessionHeader; - /** Provider state identity already frozen into the activating AgentRun. */ - providerStateIdentity?: `sha256:${string}`; store: SessionStore; /** Process-local cancellation for the execution that owns this activation. */ abortSignal?: AbortSignal; @@ -695,21 +693,43 @@ export interface BackendFactoryContext { export type BackendFactory = (ctx: BackendFactoryContext) => AgentBackend | Promise; +export type BackendPreparationContext = Pick< + BackendFactoryContext, + 'sessionId' | 'workspaceRoot' | 'header' | 'abortSignal' +>; + +export interface PreparedBackendActivation { + readonly providerStateIdentity?: `sha256:${string}`; + build(ctx: BackendFactoryContext): AgentBackend | Promise; +} + +export interface PreparedBackendFactory { + prepare(ctx: BackendPreparationContext): Promise; +} + +type BackendRegistration = BackendFactory | PreparedBackendFactory; + export class BackendRegistry { - private readonly factories = new Map(); + private readonly registrations = new Map(); - register(kind: PersistedBackendKind, factory: BackendFactory): void { - this.factories.set(kind, factory); + register(kind: PersistedBackendKind, registration: BackendRegistration): void { + this.registrations.set(kind, registration); } - async build(kind: PersistedBackendKind, ctx: BackendFactoryContext): Promise { - const f = this.factories.get(kind); - if (!f) throw new Error(`No backend factory registered for kind="${kind}"`); - return await f(ctx); + async prepare( + kind: PersistedBackendKind, + ctx: BackendPreparationContext, + ): Promise { + const registration = this.registrations.get(kind); + if (!registration) throw new Error(`No backend factory registered for kind="${kind}"`); + if (typeof registration === 'function') { + return { build: registration }; + } + return await registration.prepare(ctx); } has(kind: PersistedBackendKind): boolean { - return this.factories.has(kind); + return this.registrations.has(kind); } } @@ -766,7 +786,6 @@ interface SessionManagerBaseDeps { inspectContinuationSafety?: (sessionId: string) => Promise; continuationFailpoint?: (point: RuntimeContinuationFailpoint) => Promise; runBackendActivation?: BackendActivationBoundary; - resolveProviderStateIdentity?: (header: SessionHeader) => Promise<`sha256:${string}` | undefined>; safeBoundaryResumeEnabled?: boolean; /** Hosted composition capability. Omit for the production embedded queue. */ messageAuthority?: RuntimeMessageAuthority; @@ -1990,7 +2009,13 @@ export class SessionManager { this.deps.store.readHeader(sessionId), this.deps.runStore.listSessionRuns(sessionId), ]); - const targetProviderStateIdentity = await this.deps.resolveProviderStateIdentity?.(header); + const targetProviderStateIdentity = ( + await this.deps.backends.prepare(header.backend, { + sessionId, + workspaceRoot: header.workspaceRoot, + header, + }) + ).providerStateIdentity; admissionRoute = { runHeaders, targetProviderStateIdentity, From 7f10e43582fe910cab3f80a4deac8d7ace756b9e Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 21:15:54 +0800 Subject: [PATCH 14/16] test(storage): follow runtime schema authority Generated-by: Codex --- .../__tests__/workspace-version-authority-persistence.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts b/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts index 26fecc1d58..ecbfa62598 100644 --- a/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts +++ b/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts @@ -37,6 +37,7 @@ import { createSqliteRuntimeStore, type SqliteRuntimeStoreFailpoint, } from '../sqlite-runtime-store.js'; +import { SQLITE_RUNTIME_SCHEMA_VERSION } from '../sqlite-runtime-schema.js'; import { bindWorkspaceBaselineAuthorityStoreRootInternal, commitManagedMutationTerminalInternal, @@ -660,7 +661,7 @@ describe('workspace version persistence authority', () => { bindWorkspaceBaselineAuthorityStoreRootInternal(upgraded, TEST_STORAGE_ROOT_ID); registerWorkspaceSuccessorCandidateVerifierInternal(upgraded, verifyTestCandidate); try { - assert.equal(upgraded.schemaVersion(), 14); + assert.equal(upgraded.schemaVersion(), SQLITE_RUNTIME_SCHEMA_VERSION); assert.equal( ( await upgraded.readWorkspaceHead( From 7e2e0899cdae5998c0df0b49999ff6512f984590 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 21:47:39 +0800 Subject: [PATCH 15/16] test(runtime-host): compose continuation planning authority Generated-by: Codex --- .../src/__tests__/fixtures/execution-host-suite.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts index e7f2b177eb..641299cfb6 100644 --- a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts +++ b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts @@ -403,11 +403,15 @@ export class ExecutionFixture { try { stores = await openInteractiveExecutionStoresForWrite(owner.lease); const workspace = await resolveWorkspaceIdentity({ path: this.root }); + const backends = new BackendRegistry(); + backends.register('ai-sdk', () => { + throw new Error('pending continuation setup must not build a backend'); + }); const manager = new SessionManager({ store: stores.sessionStore, runStore: stores.agentRunStore, runtimeEventStore: stores.runtimeEventStore, - backends: new BackendRegistry(), + backends, safeBoundaryResumeEnabled: true, inspectContinuationSafety: async () => ({ workspaceIdentity: workspace.workspaceIdentity, From b92a80aa74fc1154ec6c6640c6693ab017e313f2 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 21:53:11 +0800 Subject: [PATCH 16/16] test(runtime): align replay assertions with node test Generated-by: Codex --- .../src/__tests__/session-manager.test.ts | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 6c62b731de..4d77f850d0 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -5601,7 +5601,7 @@ describe('SessionManager permission mode updates', () => { availableToolNames: ['Read'], }; const stalePlan = await manager.planSafeBoundaryContinuation(session.id, planInput); - expect(stalePlan.disposition).toBe('continue'); + assert.strictEqual(stalePlan.disposition, 'continue'); if (!stalePlan.continuation) throw new Error('expected stale continuation'); const staleContinuation = stalePlan.continuation; await store.updateHeader(session.id, { @@ -5613,13 +5613,13 @@ describe('SessionManager permission mode updates', () => { () => collectSessionEvents(manager.resumeSafeBoundaryContinuation(staleContinuation)), /replay changed after planning/, ); - expect(providerRequest).toBe(undefined); + assert.strictEqual(providerRequest, undefined); const sameProjectionStalePlan = await manager.planSafeBoundaryContinuation( session.id, planInput, ); - expect(sameProjectionStalePlan.disposition).toBe('continue'); + assert.strictEqual(sameProjectionStalePlan.disposition, 'continue'); if (!sameProjectionStalePlan.continuation) { throw new Error('expected same-projection stale continuation'); } @@ -5635,10 +5635,10 @@ describe('SessionManager permission mode updates', () => { ), /replay changed after planning/, ); - expect(providerRequest).toBe(undefined); + assert.strictEqual(providerRequest, undefined); const plan = await manager.planSafeBoundaryContinuation(session.id, planInput); - expect(plan.disposition).toBe('continue'); + assert.strictEqual(plan.disposition, 'continue'); if (!plan.continuation) throw new Error('expected continuation'); const sessionEvents = await collectSessionEvents( @@ -5647,11 +5647,11 @@ describe('SessionManager permission mode updates', () => { assert.ok(providerRequest, JSON.stringify(sessionEvents)); const promptJson = JSON.stringify(providerRequest); - expect(promptJson).not.toContain('source-only reasoning'); - expect(promptJson).not.toContain('source-only-signature'); + assert.doesNotMatch(promptJson, /source-only reasoning/); + assert.doesNotMatch(promptJson, /source-only-signature/); assert.match(promptJson, /continue across routes/, promptJson); - expect(promptJson).toContain('cross-route-read'); - expect(promptJson).toContain('package contents'); + assert.match(promptJson, /cross-route-read/); + assert.match(promptJson, /package contents/); assert.notEqual( plan.continuation.providerReplayDigest, sameProjectionStalePlan.continuation.providerReplayDigest, @@ -5660,7 +5660,8 @@ describe('SessionManager permission mode updates', () => { session.id, plan.continuation.runId, ); - expect(continuationEvents[0]?.actions?.continuationStart?.providerReplayDigest).toBe( + assert.strictEqual( + continuationEvents[0]?.actions?.continuationStart?.providerReplayDigest, plan.continuation.providerReplayDigest, ); });