From c87dcc51e9fa3403d77a673ed03bfd1e5afdd344 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 07:53:41 +0800 Subject: [PATCH 01/30] feat(core): define durable tool result projections Generated-by: Codex --- packages/core/package.json | 1 + .../durable-tool-result-projection.test.ts | 114 ++++++++++++ .../core/src/__tests__/runtime-event.test.ts | 34 ++++ .../src/durable-tool-result-projection.ts | 171 ++++++++++++++++++ packages/core/src/events.ts | 3 + packages/core/src/runtime-event.ts | 41 ++++- 6 files changed, 361 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/__tests__/durable-tool-result-projection.test.ts create mode 100644 packages/core/src/durable-tool-result-projection.ts diff --git a/packages/core/package.json b/packages/core/package.json index 7c0dfeec11..b9752a2fbc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -7,6 +7,7 @@ "sideEffects": false, "private": true, "exports": { + "./durable-tool-result-projection": "./dist/durable-tool-result-projection.js", "./canonical-runtime-event": "./dist/canonical-runtime-event.js", "./runtime-boundary": "./dist/runtime-boundary.js", "./runtime-event": "./dist/runtime-event.js", diff --git a/packages/core/src/__tests__/durable-tool-result-projection.test.ts b/packages/core/src/__tests__/durable-tool-result-projection.test.ts new file mode 100644 index 0000000000..58e5e2fb44 --- /dev/null +++ b/packages/core/src/__tests__/durable-tool-result-projection.test.ts @@ -0,0 +1,114 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + decodeDurableToolResultProjection, + DURABLE_TOOL_RESULT_PROJECTION_MAX_BYTES, +} from '../durable-tool-result-projection.js'; + +describe('durable Tool Result projection', () => { + it('accepts only the closed current-version schema', () => { + const projection = { version: 1, kind: 'text', text: 'safe' } as const; + + assert.deepEqual(decodeDurableToolResultProjection(projection), projection); + assert.throws( + () => decodeDurableToolResultProjection({ ...projection, version: 2 }), + /Invalid durable Tool Result projection/, + ); + assert.throws( + () => decodeDurableToolResultProjection({ ...projection, providerOptions: {} }), + /Invalid durable Tool Result projection/, + ); + assert.throws( + () => + decodeDurableToolResultProjection({ + version: 1, + kind: 'content', + parts: [], + }), + /Invalid durable Tool Result projection/, + ); + }); + + it('rejects projections whose serialized JSON exceeds the durable byte bound', () => { + assert.throws( + () => + decodeDurableToolResultProjection({ + version: 1, + kind: 'text', + text: 'x'.repeat(DURABLE_TOOL_RESULT_PROJECTION_MAX_BYTES), + }), + /Invalid durable Tool Result projection/, + ); + }); + + it('accepts only content-addressed references owned by the same Session protocol', () => { + assert.deepEqual( + decodeDurableToolResultProjection({ + version: 1, + kind: 'content', + parts: [ + { + kind: 'artifact', + mediaType: 'image/png', + ref: { kind: 'session_context', sessionId: 'session-1', refId: 'sha256-image' }, + }, + ], + }).kind, + 'content', + ); + assert.throws( + () => + decodeDurableToolResultProjection({ + version: 1, + kind: 'content', + parts: [ + { + kind: 'artifact', + mediaType: 'image/png', + ref: { kind: 'external_file', absolutePath: '/private/image.png' }, + }, + ], + }), + /Invalid durable Tool Result projection/, + ); + assert.throws( + () => + decodeDurableToolResultProjection({ + version: 1, + kind: 'content', + parts: [ + { + kind: 'artifact', + mediaType: 'image/png', + ref: { + kind: 'session_file', + sessionId: 'session-1', + relativePath: 'arbitrary/path.png', + }, + }, + ], + }), + /Invalid durable Tool Result projection/, + ); + }); +}); diff --git a/packages/core/src/__tests__/runtime-event.test.ts b/packages/core/src/__tests__/runtime-event.test.ts index b0e13b3ba7..31bd12c9b1 100644 --- a/packages/core/src/__tests__/runtime-event.test.ts +++ b/packages/core/src/__tests__/runtime-event.test.ts @@ -498,6 +498,40 @@ describe('RuntimeEvent content variants', () => { }); }); +test('rejects a Tool Result projection that references another Session artifact', () => { + assert.throws( + () => + decodeRuntimeEvent( + baseEvent({ + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'call-1', + name: 'Read', + result: { kind: 'image' }, + modelProjection: { + version: 1, + kind: 'content', + parts: [ + { + kind: 'artifact', + mediaType: 'image/png', + ref: { + kind: 'session_context', + sessionId: 'another-session', + refId: 'image-1', + }, + }, + ], + }, + }, + }), + ), + /Invalid RuntimeEvent schema/, + ); +}); + describe('RuntimeEvent actions', () => { test('binds the managed mutation digest to its canonical execution semantics', () => { const canonicalProfile = JSON.stringify({ diff --git a/packages/core/src/durable-tool-result-projection.ts b/packages/core/src/durable-tool-result-projection.ts new file mode 100644 index 0000000000..67a0f9fe18 --- /dev/null +++ b/packages/core/src/durable-tool-result-projection.ts @@ -0,0 +1,171 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { isCanonicalStorageRef, type StorageRef } from './events.js'; +import { isCanonicalArtifactEntityId } from './artifacts.js'; +import { hasExactShape, isRecord } from './record-schema.js'; +import { serializedByteLength } from './serialized-byte-length.js'; + +export const DURABLE_TOOL_RESULT_PROJECTION_VERSION = 1 as const; +export const DURABLE_TOOL_RESULT_PROJECTION_MAX_BYTES = 256 * 1024; +export const DURABLE_TOOL_RESULT_PROJECTION_MAX_PARTS = 64; +export const DURABLE_TOOL_RESULT_PROJECTION_FAILURE_MESSAGE = + 'The tool completed, but its model-visible result could not be projected safely.'; + +export type DurableProjectionJson = + | null + | boolean + | number + | string + | DurableProjectionJson[] + | { [key: string]: DurableProjectionJson }; + +export type DurableProjectionArtifactRef = Extract< + StorageRef, + { kind: 'session_context' | 'session_file' } +>; + +export type DurableToolResultProjectionPart = + | { kind: 'text'; text: string } + | { + kind: 'artifact'; + mediaType: string; + ref: DurableProjectionArtifactRef; + }; + +export type DurableToolResultProjection = + | { version: 1; kind: 'text'; text: string; isError?: true } + | { version: 1; kind: 'json'; value: DurableProjectionJson; isError?: true } + | { version: 1; kind: 'content'; parts: DurableToolResultProjectionPart[] } + | { version: 1; kind: 'execution_denied'; reason?: string } + | { + version: 1; + kind: 'failure'; + reason: 'projection_failed'; + message: typeof DURABLE_TOOL_RESULT_PROJECTION_FAILURE_MESSAGE; + }; + +export const DURABLE_TOOL_RESULT_PROJECTION_FAILURE: DurableToolResultProjection = Object.freeze({ + version: DURABLE_TOOL_RESULT_PROJECTION_VERSION, + kind: 'failure', + reason: 'projection_failed', + message: DURABLE_TOOL_RESULT_PROJECTION_FAILURE_MESSAGE, +}); + +export function decodeDurableToolResultProjection(value: unknown): DurableToolResultProjection { + if ( + !isDurableToolResultProjection(value) || + serializedByteLength(value, DURABLE_TOOL_RESULT_PROJECTION_MAX_BYTES) > + DURABLE_TOOL_RESULT_PROJECTION_MAX_BYTES + ) { + throw new Error('Invalid durable Tool Result projection'); + } + return value; +} + +function isDurableToolResultProjection(value: unknown): value is DurableToolResultProjection { + if (!isRecord(value) || value.version !== DURABLE_TOOL_RESULT_PROJECTION_VERSION) return false; + switch (value.kind) { + case 'text': + return ( + hasExactShape(value, { + required: ['version', 'kind', 'text'], + allowed: new Set(['version', 'kind', 'text', 'isError']), + }) && + typeof value.text === 'string' && + (value.isError === undefined || value.isError === true) + ); + case 'json': + return ( + hasExactShape(value, { + required: ['version', 'kind', 'value'], + allowed: new Set(['version', 'kind', 'value', 'isError']), + }) && + isDurableProjectionJson(value.value) && + (value.isError === undefined || value.isError === true) + ); + case 'content': + return ( + hasExactShape(value, { + required: ['version', 'kind', 'parts'], + allowed: new Set(['version', 'kind', 'parts']), + }) && + Array.isArray(value.parts) && + value.parts.length > 0 && + value.parts.length <= DURABLE_TOOL_RESULT_PROJECTION_MAX_PARTS && + value.parts.every(isProjectionPart) + ); + case 'execution_denied': + return ( + hasExactShape(value, { + required: ['version', 'kind'], + allowed: new Set(['version', 'kind', 'reason']), + }) && + (value.reason === undefined || typeof value.reason === 'string') + ); + case 'failure': + return ( + hasExactShape(value, { + required: ['version', 'kind', 'reason', 'message'], + allowed: new Set(['version', 'kind', 'reason', 'message']), + }) && + value.reason === 'projection_failed' && + value.message === DURABLE_TOOL_RESULT_PROJECTION_FAILURE_MESSAGE + ); + default: + return false; + } +} + +function isProjectionPart(value: unknown): value is DurableToolResultProjectionPart { + if (!isRecord(value)) return false; + if (value.kind === 'text') { + return ( + hasExactShape(value, { + required: ['kind', 'text'], + allowed: new Set(['kind', 'text']), + }) && typeof value.text === 'string' + ); + } + return ( + value.kind === 'artifact' && + hasExactShape(value, { + required: ['kind', 'mediaType', 'ref'], + allowed: new Set(['kind', 'mediaType', 'ref']), + }) && + typeof value.mediaType === 'string' && + value.mediaType.length > 0 && + isCanonicalStorageRef(value.ref) && + (value.ref.kind === 'session_context' || + (value.ref.kind === 'session_file' && isCanonicalArtifactEntityId(value.ref.relativePath))) + ); +} + +function isDurableProjectionJson(value: unknown): value is DurableProjectionJson { + if ( + value === null || + typeof value === 'string' || + typeof value === 'boolean' || + (typeof value === 'number' && Number.isFinite(value)) + ) { + return true; + } + if (Array.isArray(value)) return value.every(isDurableProjectionJson); + return isRecord(value) && Object.values(value).every(isDurableProjectionJson); +} diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 386b5c598b..c5ccd3b0ff 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -48,6 +48,7 @@ import type { export { SHELL_RUN_SOURCE_TOOL_CALL_ID_MAX_BYTES } from './shell-run.js'; import { type TokenUsageFields } from './usage-record-schema.js'; import { defineObjectShape, hasExactShape, isRecord } from './record-schema.js'; +import type { DurableToolResultProjection } from './durable-tool-result-projection.js'; export const TOOL_OUTPUT_STREAMS = ['stdout', 'stderr'] as const; export const TOOL_OUTPUT_DELTA_MAX_CHARS = 8192; @@ -698,6 +699,8 @@ export interface ToolResultEvent extends BaseEvent, ToolActivityIdentity { providerExecuted?: boolean; /** Raw provider result retained for provider-native replay; never rendered directly. */ providerOutput?: unknown; + /** Provider-neutral model-visible output computed before durable publication. */ + modelProjection?: DurableToolResultProjection; /** The transport omitted durable result content; consumers must not treat the placeholder as authoritative. */ contentOmitted?: true; isError: boolean; diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index 644f3c89fe..8dbe8e31e9 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -63,6 +63,10 @@ import { isRuntimeEventWorkspaceFactEnvelope, type RuntimeEventWorkspaceFactEnvelope, } from './workspace-version-authority.js'; +import { + decodeDurableToolResultProjection, + type DurableToolResultProjection, +} from './durable-tool-result-projection.js'; // ============================================================================ // Role / Author / Status @@ -188,6 +192,8 @@ export interface RuntimeEventFunctionResponseContent { providerExecuted?: boolean; /** Raw provider result retained for provider-native replay; never rendered directly. */ providerOutput?: unknown; + /** Frozen provider-neutral content consumed by every model-history projection. */ + modelProjection?: DurableToolResultProjection; } export interface RuntimeEventErrorContent { @@ -249,6 +255,8 @@ export type ToolBoundaryProtocol = typeof TOOL_BOUNDARY_PROTOCOL_V1; */ export interface RuntimeEventToolDispatch { protocol: ToolBoundaryProtocol; + /** New writes require this exact durable Tool Result projection protocol. */ + resultProjectionVersion?: 1; operationId: string; providerToolCallId: string; toolName: string; @@ -548,7 +556,7 @@ const FUNCTION_CALL_CONTENT_SHAPE = defineObjectShape()( ['kind', 'id', 'name', 'result'], - ['isError', 'providerExecuted', 'providerOutput'], + ['isError', 'providerExecuted', 'providerOutput', 'modelProjection'], ); const ERROR_CONTENT_SHAPE = defineObjectShape()( ['kind', 'message'], @@ -601,7 +609,7 @@ const RUNTIME_TOOL_DISPATCH_SHAPE = defineObjectShape( 'canonicalArgsHash', 'recoveryMode', ], - ['managedMutation'], + ['managedMutation', 'resultProjectionVersion'], ); const RUNTIME_MANAGED_WORKSPACE_MUTATION_SHAPE = defineObjectShape()( @@ -712,6 +720,7 @@ export function decodeRuntimeEvent(value: unknown): RuntimeEvent { !isOptionalMember(value.modelVisibility, RUNTIME_EVENT_MODEL_VISIBILITIES) || (value.status !== undefined && !isRuntimeEventStatus(value.status)) || (value.content !== undefined && !isRuntimeEventContent(value.content)) || + !hasOwnedModelProjection(value.content, value.sessionId) || (value.actions !== undefined && !isRuntimeEventActions(value.actions)) || (value.refs !== undefined && !isRuntimeEventRefs(value.refs)) ) { @@ -733,6 +742,20 @@ export function decodeRuntimeEvent(value: unknown): RuntimeEvent { return value as unknown as RuntimeEvent; } +function hasOwnedModelProjection(content: unknown, sessionId: string): boolean { + if (!isRecord(content) || content.kind !== 'function_response') return true; + const projection = content.modelProjection; + if (!isRecord(projection) || projection.kind !== 'content' || !Array.isArray(projection.parts)) { + return true; + } + return projection.parts.every( + (part) => + !isRecord(part) || + part.kind !== 'artifact' || + (isRecord(part.ref) && part.ref.sessionId === sessionId), + ); +} + function isRuntimeEventContent(value: unknown): value is RuntimeEventContent { if (!isRecord(value)) return false; switch (value.kind) { @@ -777,7 +800,9 @@ function isRuntimeEventContent(value: unknown): value is RuntimeEventContent { typeof value.name === 'string' && Object.hasOwn(value, 'result') && (value.isError === undefined || typeof value.isError === 'boolean') && - (value.providerExecuted === undefined || typeof value.providerExecuted === 'boolean') + (value.providerExecuted === undefined || typeof value.providerExecuted === 'boolean') && + (value.modelProjection === undefined || + decodesDurableToolResultProjection(value.modelProjection)) ); case 'error': return ( @@ -792,6 +817,15 @@ function isRuntimeEventContent(value: unknown): value is RuntimeEventContent { } } +function decodesDurableToolResultProjection(value: unknown): boolean { + try { + decodeDurableToolResultProjection(value); + return true; + } catch { + return false; + } +} + function isTurnOrigin(value: unknown): value is TurnOrigin { return decodeTurnOrigin(value) !== undefined; } @@ -896,6 +930,7 @@ function isRuntimeToolDispatch(value: unknown): value is RuntimeEventToolDispatc isRecord(value) && hasExactShape(value, RUNTIME_TOOL_DISPATCH_SHAPE) && value.protocol === TOOL_BOUNDARY_PROTOCOL_V1 && + (value.resultProjectionVersion === undefined || value.resultProjectionVersion === 1) && typeof value.operationId === 'string' && typeof value.providerToolCallId === 'string' && typeof value.toolName === 'string' && From 9c30108af8e6c047b008671f818fdb7a709cc3f2 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 07:53:55 +0800 Subject: [PATCH 02/30] feat(runtime): add durable projection codec Generated-by: Codex --- packages/runtime/package.json | 1 + .../durable-tool-result-projection.test.ts | 147 ++++++ .../src/durable-tool-result-projection.ts | 423 ++++++++++++++++++ 3 files changed, 571 insertions(+) create mode 100644 packages/runtime/src/__tests__/durable-tool-result-projection.test.ts create mode 100644 packages/runtime/src/durable-tool-result-projection.ts diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 35b4ad5e48..fd2c28d77e 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -13,6 +13,7 @@ "./shell-tools": "./dist/shell-tools.js", "./shell-run-manager": "./dist/shell-run-manager.js", "./deep-research-tools": "./dist/deep-research-tools.js", + "./durable-tool-result-projection": "./dist/durable-tool-result-projection.js", "./tool-artifacts": "./dist/tool-artifacts.js", "./model-factory": "./dist/model-factory.js", "./model-runtime": "./dist/model-runtime.js", diff --git a/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts b/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts new file mode 100644 index 0000000000..25e9948fd2 --- /dev/null +++ b/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts @@ -0,0 +1,147 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + decodeEffectiveToolResultProjection, + encodeDefaultDurableToolResultOutput, + encodeDurableToolResultOutput, +} from '../durable-tool-result-projection.js'; + +describe('durable Tool Result projection codec', () => { + it('redacts text and JSON leaves before they become durable model content', () => { + assert.deepEqual( + encodeDurableToolResultOutput( + { type: 'text', value: 'Authorization: Bearer sk-live-secret-token-value' }, + 'session-1', + ), + { + version: 1, + kind: 'text', + text: 'Authorization: Bearer [redacted]', + }, + ); + assert.deepEqual( + encodeDurableToolResultOutput( + { + type: 'json', + value: { password: 'correct-horse-battery-staple', keep: 'visible' }, + }, + 'session-1', + ), + { + version: 1, + kind: 'json', + value: { password: '[redacted]', keep: 'visible' }, + }, + ); + }); + + it('never persists inline binary or provider options', () => { + const projection = encodeDurableToolResultOutput( + { + type: 'content', + value: [ + { type: 'text', text: 'visible', providerOptions: { provider: { opaque: true } } }, + { + type: 'file', + data: { type: 'data', data: 'unbounded-base64-payload' }, + mediaType: 'image/png', + providerOptions: { provider: { opaque: true } }, + }, + ], + }, + 'session-1', + ); + + assert.doesNotMatch(JSON.stringify(projection), /unbounded-base64-payload|providerOptions/); + assert.deepEqual(projection, { + version: 1, + kind: 'content', + parts: [ + { kind: 'text', text: 'visible' }, + { + kind: 'text', + text: '[Binary tool output omitted from the durable model projection; repeat the tool call if it is still needed.]', + }, + ], + }); + }); + + it('maps opaque and oversized JSON to the same deterministic failure sentinel', () => { + const opaque = encodeDurableToolResultOutput( + { type: 'json', value: new URL('https://example.test') as never }, + 'session-1', + ); + const oversized = encodeDurableToolResultOutput( + { type: 'json', value: { body: 'x'.repeat(300_000) } }, + 'session-1', + ); + + assert.deepEqual(opaque, oversized); + assert.deepEqual(opaque, { + version: 1, + kind: 'failure', + reason: 'projection_failed', + message: 'The tool completed, but its model-visible result could not be projected safely.', + }); + }); + + it('fails deterministically instead of silently dropping excess content parts', () => { + const projection = encodeDurableToolResultOutput( + { + type: 'content', + value: Array.from({ length: 65 }, (_, index) => ({ + type: 'text' as const, + text: `part-${index}`, + })), + }, + 'session-1', + ); + + assert.equal(projection.kind, 'failure'); + }); + + it('validates default image refs through the same closed schema', () => { + const legacyImage = { + kind: 'image', + mimeType: 'image/png', + ref: { + kind: 'session_file', + sessionId: 'session-1', + relativePath: 'legacy/path.png', + }, + } as const; + assert.equal(encodeDefaultDurableToolResultOutput(legacyImage, 'session-1').kind, 'failure'); + assert.deepEqual( + decodeEffectiveToolResultProjection( + { + kind: 'function_response', + id: 'legacy-image-1', + name: 'Read', + result: legacyImage, + }, + 'session-1', + ), + { kind: 'legacy_output', output: legacyImage }, + ); + }); +}); diff --git a/packages/runtime/src/durable-tool-result-projection.ts b/packages/runtime/src/durable-tool-result-projection.ts new file mode 100644 index 0000000000..c3d527da9d --- /dev/null +++ b/packages/runtime/src/durable-tool-result-projection.ts @@ -0,0 +1,423 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + decodeDurableToolResultProjection, + DURABLE_TOOL_RESULT_PROJECTION_FAILURE, + DURABLE_TOOL_RESULT_PROJECTION_MAX_PARTS, + DURABLE_TOOL_RESULT_PROJECTION_VERSION, + type DurableProjectionArtifactRef, + type DurableProjectionJson, + type DurableToolResultProjection, + type DurableToolResultProjectionPart, +} from '@maka/core/durable-tool-result-projection'; +import { MAX_READ_IMAGE_BYTES } from '@maka/core/attachments'; +import { isCanonicalArtifactEntityId } from '@maka/core/artifacts'; +import { isCanonicalStorageRef } from '@maka/core/events'; +import type { ToolResultContent } from '@maka/core/events'; +import { redactSecrets } from '@maka/core/redaction'; +import type { RuntimeEventFunctionResponseContent } from '@maka/core/runtime-event'; +import { decodeCanonicalShellToolResultContent } from '@maka/core/shell-run-result'; +import { markPersisted } from '@maka/core/persisted-value'; +import { decodePersistedToolResultContent } from '@maka/core/tool-result-record-schema'; + +import type { ToolResultOutput } from './model-protocol.js'; +import { toolResultOutput } from './tool-result-output.js'; +import { withToolResultArchiveResourceRef } from './tool-result-archive.js'; +import { projectBashToolResultForModel } from './bash-model-output.js'; +import { projectFileWriteToolResultForModel } from './file-tool-model-output.js'; + +const OMITTED_BINARY_TEXT = + '[Binary tool output omitted from the durable model projection; repeat the tool call if it is still needed.]'; +const MAX_JSON_DEPTH = 32; +const MAX_JSON_NODES = 20_000; + +/** + * The only new-write codec from Runtime's tool-output contract into the + * provider-neutral durable projection protocol. It is total by construction: + * invalid, unsafe, or oversized output becomes one stable failure sentinel. + */ +export function encodeDurableToolResultOutput( + output: ToolResultOutput, + sessionId: string, +): DurableToolResultProjection { + try { + const projection = encodeOutput(output, sessionId); + return decodeDurableToolResultProjection(projection); + } catch { + return DURABLE_TOOL_RESULT_PROJECTION_FAILURE; + } +} + +export type DurableProjectionArtifactPersister = (input: { + bytes: Uint8Array; + mediaType: string; +}) => Promise; + +export function encodeDurableToolResultOutputWithArtifacts( + output: ToolResultOutput, + sessionId: string, + persistArtifact: DurableProjectionArtifactPersister | undefined, +): DurableToolResultProjection | PromiseLike { + if (!persistArtifact || output.type !== 'content' || !hasInlineImage(output)) { + return encodeDurableToolResultOutput(output, sessionId); + } + return (async () => { + try { + const value: Extract['value'] = []; + for (const part of output.value) { + if ( + part.type !== 'file' || + part.data.type !== 'data' || + !part.mediaType.toLowerCase().startsWith('image/') + ) { + value.push(part); + continue; + } + const bytes = decodeBoundedImageData(part.data.data); + const ref = await persistArtifact({ bytes, mediaType: part.mediaType }); + value.push({ ...part, data: { ref } } as never); + } + return encodeDurableToolResultOutput({ type: 'content', value }, sessionId); + } catch { + return DURABLE_TOOL_RESULT_PROJECTION_FAILURE; + } + })(); +} + +export function encodeDefaultDurableToolResultOutput( + result: unknown, + sessionId: string, +): DurableToolResultProjection { + const image = sessionImageResult(result, sessionId); + if (image) { + try { + return decodeDurableToolResultProjection({ + version: DURABLE_TOOL_RESULT_PROJECTION_VERSION, + kind: 'content', + parts: [{ kind: 'artifact', mediaType: image.mimeType, ref: image.ref }], + }); + } catch { + return DURABLE_TOOL_RESULT_PROJECTION_FAILURE; + } + } + return encodeDurableToolResultOutput( + typeof result === 'string' + ? { type: 'text', value: result } + : { type: 'json', value: result as never }, + sessionId, + ); +} + +export function durableProjectionHasArtifacts(projection: DurableToolResultProjection): boolean { + return projection.kind === 'content' && projection.parts.some((part) => part.kind === 'artifact'); +} + +export function durableProjectionToToolResultOutput( + projection: DurableToolResultProjection, +): ToolResultOutput { + switch (projection.kind) { + case 'text': + return projection.isError + ? { type: 'error-text', value: projection.text } + : { type: 'text', value: projection.text }; + case 'json': + return projection.isError + ? { type: 'error-json', value: projection.value } + : { type: 'json', value: projection.value }; + case 'content': + return { + type: 'content', + value: projection.parts.map((part) => + part.kind === 'text' + ? { type: 'text' as const, text: part.text } + : { + type: 'text' as const, + text: `[Artifact ${JSON.stringify( + part.ref.kind === 'session_context' ? part.ref.refId : part.ref.relativePath, + )} (${part.mediaType}) is stored in this Session.]`, + }, + ), + }; + case 'execution_denied': + return { + type: 'execution-denied', + ...(projection.reason !== undefined ? { reason: projection.reason } : {}), + }; + case 'failure': + return { type: 'error-text', value: projection.message }; + } +} + +export type EffectiveToolResultProjection = + | { + kind: 'projection'; + source: 'durable' | 'compatibility'; + projection: DurableToolResultProjection; + legacyOutput: unknown; + } + | { kind: 'provider_native'; output: unknown } + | { kind: 'legacy_output'; output: unknown } + | { kind: 'invalid_legacy'; message: string }; + +/** The single compatibility boundary for both current and legacy response events. */ +export function decodeEffectiveToolResultProjection( + content: RuntimeEventFunctionResponseContent, + sessionId: string, +): EffectiveToolResultProjection { + if (content.providerExecuted && content.providerOutput !== undefined) { + return { kind: 'provider_native', output: content.providerOutput }; + } + if (content.modelProjection !== undefined) { + try { + return { + kind: 'projection', + source: 'durable', + projection: decodeDurableToolResultProjection(content.modelProjection), + legacyOutput: content.result, + }; + } catch { + return { + kind: 'projection', + source: 'durable', + projection: DURABLE_TOOL_RESULT_PROJECTION_FAILURE, + legacyOutput: content.result, + }; + } + } + + let output = withToolResultArchiveResourceRef(content.result); + if (isRetiredExploreAgentResult(output)) { + try { + output = decodePersistedToolResultContent(markPersisted(output)); + } catch { + return { + kind: 'invalid_legacy', + message: 'function_response contains an invalid retired tool result', + }; + } + } + const shellResult = decodeCanonicalShellToolResultContent(output); + if (shellResult.state === 'invalid') { + return { + kind: 'invalid_legacy', + message: 'function_response contains an invalid shell tool result', + }; + } + if (shellResult.state === 'valid') output = shellResult.content; + output = + content.name === 'Bash' + ? projectBashToolResultForModel(output) + : projectFileWriteToolResultForModel(content.name, output); + const projection = + content.isError === true + ? encodeDurableToolResultOutput(compatibilityErrorOutput(output), sessionId) + : encodeDefaultDurableToolResultOutput(output, sessionId); + if (projection.kind === 'failure' && isLegacyPathImageResult(output, sessionId)) { + return { kind: 'legacy_output', output }; + } + return { + kind: 'projection', + source: 'compatibility', + projection, + legacyOutput: output, + }; +} + +function compatibilityErrorOutput(output: unknown): ToolResultOutput { + return output !== null && + typeof output === 'object' && + !Array.isArray(output) && + (output as { kind?: unknown }).kind === 'text' && + typeof (output as { text?: unknown }).text === 'string' + ? { type: 'error-text', value: new Error((output as { text: string }).text).toString() } + : toolResultOutput(output, true); +} + +export function compatibilityToolResultProjection( + content: RuntimeEventFunctionResponseContent, + sessionId: string, +): DurableToolResultProjection | undefined { + const effective = decodeEffectiveToolResultProjection(content, sessionId); + if (effective.kind === 'provider_native') return undefined; + if (effective.kind === 'legacy_output') return DURABLE_TOOL_RESULT_PROJECTION_FAILURE; + return effective.kind === 'projection' + ? effective.projection + : DURABLE_TOOL_RESULT_PROJECTION_FAILURE; +} + +function encodeOutput(output: ToolResultOutput, sessionId: string): DurableToolResultProjection { + switch (output.type) { + case 'text': + case 'error-text': + return { + version: DURABLE_TOOL_RESULT_PROJECTION_VERSION, + kind: 'text', + text: redactSecrets(output.value), + ...(output.type === 'error-text' ? { isError: true as const } : {}), + }; + case 'json': + case 'error-json': + return { + version: DURABLE_TOOL_RESULT_PROJECTION_VERSION, + kind: 'json', + value: sanitizeJson(output.value), + ...(output.type === 'error-json' ? { isError: true as const } : {}), + }; + case 'execution-denied': + return { + version: DURABLE_TOOL_RESULT_PROJECTION_VERSION, + kind: 'execution_denied', + ...(output.reason !== undefined ? { reason: redactSecrets(output.reason) } : {}), + }; + case 'content': { + if (output.value.length > DURABLE_TOOL_RESULT_PROJECTION_MAX_PARTS) { + throw new Error('Tool Result content exceeds the durable part limit'); + } + const parts: DurableToolResultProjectionPart[] = []; + for (const part of output.value) { + if (part.type === 'text') { + parts.push({ kind: 'text', text: redactSecrets(part.text) }); + continue; + } + if (part.type === 'file') { + const ref = readSessionArtifactRef(part, sessionId); + if (ref) parts.push({ kind: 'artifact', mediaType: part.mediaType, ref }); + else parts.push({ kind: 'text', text: OMITTED_BINARY_TEXT }); + } + } + if (parts.length === 0) parts.push({ kind: 'text', text: 'Tool completed with no content.' }); + return { version: DURABLE_TOOL_RESULT_PROJECTION_VERSION, kind: 'content', parts }; + } + } +} + +function readSessionArtifactRef( + part: Extract['value'][number], + sessionId: string, +) { + if (part.type !== 'file') return undefined; + const data = part.data as unknown; + if (!data || typeof data !== 'object' || Array.isArray(data)) return undefined; + const ref = (data as { ref?: unknown }).ref; + return isCanonicalStorageRef(ref) && + (ref.kind === 'session_context' || ref.kind === 'session_file') && + ref.sessionId === sessionId + ? ref + : undefined; +} + +function hasInlineImage(output: Extract): boolean { + return output.value.some( + (part) => + part.type === 'file' && + part.data.type === 'data' && + part.mediaType.toLowerCase().startsWith('image/'), + ); +} + +function decodeBoundedImageData(data: unknown): Uint8Array { + let bytes: Uint8Array; + if (typeof data === 'string') { + if (data.length > Math.ceil((MAX_READ_IMAGE_BYTES * 4) / 3) + 4) { + throw new Error('Inline image exceeds the artifact byte limit'); + } + const decoded = Buffer.from(data, 'base64'); + if (decoded.toString('base64') !== data) + throw new Error('Inline image is not canonical base64'); + bytes = decoded; + } else if (data instanceof ArrayBuffer) { + bytes = new Uint8Array(data.slice(0)); + } else if (ArrayBuffer.isView(data)) { + bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice(); + } else { + throw new Error('Inline image data is not representable'); + } + if (bytes.byteLength > MAX_READ_IMAGE_BYTES) { + throw new Error('Inline image exceeds the artifact byte limit'); + } + return bytes; +} + +function sessionImageResult(result: unknown, sessionId: string) { + if (!result || typeof result !== 'object' || Array.isArray(result)) return undefined; + const image = result as { kind?: unknown; mimeType?: unknown; ref?: unknown }; + return image.kind === 'image' && + typeof image.mimeType === 'string' && + image.mimeType.length > 0 && + isCanonicalStorageRef(image.ref) && + (image.ref.kind === 'session_context' || image.ref.kind === 'session_file') && + image.ref.sessionId === sessionId + ? { mimeType: image.mimeType, ref: image.ref } + : undefined; +} + +function isLegacyPathImageResult(result: unknown, sessionId: string): boolean { + const image = sessionImageResult(result, sessionId); + return image?.ref.kind === 'session_file' && !isCanonicalArtifactEntityId(image.ref.relativePath); +} + +function sanitizeJson(value: unknown): DurableProjectionJson { + const state = { nodes: 0 }; + const strictJson = sanitizeJsonValue(value, state, 0); + return JSON.parse(redactSecrets(JSON.stringify(strictJson))) as DurableProjectionJson; +} + +function sanitizeJsonValue( + value: unknown, + state: { nodes: number }, + depth: number, +): DurableProjectionJson { + state.nodes += 1; + if (state.nodes > MAX_JSON_NODES || depth > MAX_JSON_DEPTH) throw new Error('JSON exceeds limit'); + if (value === null) return null; + if (typeof value === 'string') return value; + if (typeof value === 'boolean') return value; + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (Array.isArray(value)) { + return value.map((item) => sanitizeJsonValue(item, state, depth + 1)); + } + if (!value || typeof value !== 'object') throw new Error('JSON is not representable'); + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new Error('JSON contains an opaque object'); + } + if (typeof (value as { toJSON?: unknown }).toJSON === 'function') { + throw new Error('JSON contains an opaque serializer'); + } + const result: Record = {}; + for (const [key, item] of Object.entries(value)) { + Object.defineProperty(result, key, { + enumerable: true, + configurable: true, + writable: true, + value: sanitizeJsonValue(item, state, depth + 1), + }); + } + return result; +} + +function isRetiredExploreAgentResult(value: unknown): boolean { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + (value as { kind?: unknown }).kind === 'explore_agent' + ); +} From fd0266d069aaba77a9e51c68230a7e5c44a8322e Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 07:54:51 +0800 Subject: [PATCH 03/30] feat(runtime): commit projections atomically at T2 Generated-by: Codex --- .../src/__tests__/ai-sdk-backend.test.ts | 1 + .../tool-runtime-durable-boundary.test.ts | 153 ++++++++++++++ .../__tests__/tool-runtime-settlement.test.ts | 27 ++- .../tool-runtime-sqlite-boundary.test.ts | 40 +++- packages/runtime/src/ai-sdk-backend.ts | 69 ++++++- .../src/session-event-runtime-mapper.ts | 3 + packages/runtime/src/tool-runtime.ts | 195 +++++++++++++++--- .../__tests__/sqlite-runtime-store.test.ts | 70 ++++++- packages/storage/src/sqlite-runtime-store.ts | 7 + 9 files changed, 511 insertions(+), 54 deletions(-) diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 8ac41d9d43..52cc3e2e03 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -6928,6 +6928,7 @@ describe('AiSdkBackend error surfaces', () => { await turnScope(backend, 'turn-1').toolRuntime.writeSyntheticToolResult( 'tool-1', 'turn-1', + 'Bash', 'failed with api_key=sk-live-secret-token-value', { push: (event) => { diff --git a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts index e6a56f7c66..257d894e76 100644 --- a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts @@ -24,6 +24,7 @@ import type { LlmConnection } from '@maka/core/llm-connections'; import type { SessionEvent } from '@maka/core/events'; import type { SessionHeader, StoredMessage } from '@maka/core/session'; import { ToolOutcomeUnknownError } from '@maka/core/events'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { RuntimeCommitSink, ToolOutcomeCommit, @@ -194,6 +195,10 @@ describe('ToolRuntime durable boundary', () => { prepared[0]?.dispatchRuntimeEvent.actions?.toolDispatch?.protocol, 't1_after_preflight_v1', ); + assert.equal( + prepared[0]?.dispatchRuntimeEvent.actions?.toolDispatch?.resultProjectionVersion, + 1, + ); assert.equal(prepared[0]?.dispatchRuntimeEvent.content, undefined); assert.equal(outcomes[0]?.runtimeEvent.content?.kind, 'function_response'); assert.equal(prepared[0]?.operationId, outcomes[0]?.operationId); @@ -202,6 +207,130 @@ describe('ToolRuntime durable boundary', () => { assert.equal(outcomes[0]?.runtimeEvent.refs?.operationId, prepared[0]?.operationId); }); + it('commits the completed outcome with its model projection in T2', async () => { + const order: string[] = []; + const outcomes: ToolOutcomeCommit[] = []; + const harness = makeHarness({ + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async (input) => { + outcomes.push(input); + order.push('t2'); + return { created: true, runtimeEventSeq: 2 }; + }, + }); + const projectedTool = tool(() => ({ private: 'raw execution fact' })); + projectedTool.toModelOutput = () => { + order.push('project'); + return { type: 'text', value: 'bounded model fact' }; + }; + + await harness.execute(projectedTool); + + assert.deepEqual(order, ['project', 't2']); + const response = outcomes[0]?.runtimeEvent.content; + assert.deepEqual( + response?.kind === 'function_response' ? response.modelProjection : undefined, + { + version: 1, + kind: 'text', + text: 'bounded model fact', + }, + ); + }); + + it('commits one deterministic projection fallback without repeating a completed tool', async () => { + let implementationCalls = 0; + const outcomes: ToolOutcomeCommit[] = []; + const harness = makeHarness({ + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async (input) => { + outcomes.push(input); + return { created: true, runtimeEventSeq: 2 }; + }, + }); + const unprojectableTool = tool(() => { + implementationCalls += 1; + return { private: 'completed execution fact' }; + }); + unprojectableTool.toModelOutput = () => { + throw new Error('projection implementation failed'); + }; + + assert.deepEqual(await harness.execute(unprojectableTool), { + private: 'completed execution fact', + }); + + assert.equal(implementationCalls, 1); + assert.equal(outcomes.length, 1); + const response = outcomes[0]?.runtimeEvent.content; + assert.deepEqual( + response?.kind === 'function_response' ? response.modelProjection : undefined, + { + version: 1, + kind: 'failure', + reason: 'projection_failed', + message: 'The tool completed, but its model-visible result could not be projected safely.', + }, + ); + }); + + it('persists inline image output as a Session artifact before committing T2', async () => { + const order: string[] = []; + const outcomes: ToolOutcomeCommit[] = []; + const artifactRef = { + kind: 'session_file' as const, + sessionId: 'session-1', + relativePath: 'artifact-1', + }; + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async (input) => { + outcomes.push(input); + order.push('t2'); + return { created: true, runtimeEventSeq: 2 }; + }, + }, + undefined, + 'run-1', + { + persistDurableProjectionArtifact: async (input) => { + order.push('artifact'); + assert.equal(input.turnId, 'turn-1'); + assert.equal(input.toolCallId, 'provider-call-1'); + assert.equal(input.mediaType, 'image/png'); + assert.deepEqual([...input.bytes], [137, 80, 78, 71]); + return artifactRef; + }, + }, + ); + const imageTool = tool(() => ({ private: 'raw execution fact' })); + imageTool.toModelOutput = () => ({ + type: 'content', + value: [ + { + type: 'file', + data: { type: 'data', data: Buffer.from([137, 80, 78, 71]).toString('base64') }, + mediaType: 'image/png', + }, + ], + }); + + await harness.execute(imageTool); + + assert.deepEqual(order, ['artifact', 't2']); + const response = outcomes[0]?.runtimeEvent.content; + assert.deepEqual( + response?.kind === 'function_response' ? response.modelProjection : undefined, + { + version: 1, + kind: 'content', + parts: [{ kind: 'artifact', mediaType: 'image/png', ref: artifactRef }], + }, + ); + assert.doesNotMatch(JSON.stringify(response), /iVBORw/); + }); + it('adopts an owner-committed managed successor without invoking generic T2', async () => { const order: string[] = []; const prepared: ToolPreparedCommit[] = []; @@ -1627,6 +1756,9 @@ function managedOutcomeEvent( isError: boolean, options: { durationMs?: number; + modelProjection?: NonNullable< + Extract['modelProjection'] + >; origin?: 'provider' | 'code_mode'; modelVisibility?: 'visible' | 'hidden'; toolCallId?: string; @@ -1653,6 +1785,7 @@ function managedOutcomeEvent( name: 'Write', result, ...(isError ? { isError: true } : {}), + modelProjection: options.modelProjection ?? managedModelProjection(result), }, refs: { operationId, @@ -1664,6 +1797,26 @@ function managedOutcomeEvent( }; } +function managedModelProjection( + result: unknown, +): NonNullable['modelProjection']> { + const raw = + result && + typeof result === 'object' && + !Array.isArray(result) && + (result as { kind?: unknown }).kind === 'json' + ? (result as { value: unknown }).value + : result; + const providerError = + raw && typeof raw === 'object' && !Array.isArray(raw) + ? (raw as { error?: unknown }).error + : undefined; + if (typeof providerError === 'string') { + return { version: 1, kind: 'text', text: `Error: ${providerError}`, isError: true }; + } + return { version: 1, kind: 'json', value: raw as never }; +} + function managedMutationDispatch() { return { protocol: 'managed_mutation_v2' as const, diff --git a/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts index 12a34a65c6..c8b28024ff 100644 --- a/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts @@ -343,12 +343,26 @@ describe('ToolRuntime settlement', () => { assert.deepEqual(settlement.modelOutput, { type: 'json', value: result }); }); - it('uses the runtime model-output materializer for default tool results', async () => { - const result = { kind: 'image', ref: 'artifact-1' }; + it('materializes a durable artifact projection for the current continuation', async () => { + const result = { + kind: 'image', + mimeType: 'image/png', + ref: { kind: 'session_context' as const, sessionId: 'session-1', refId: 'artifact-1' }, + }; const runtime = makeRuntime({ - materializeDefaultToolResultOutput: async ({ toolCallId, output }) => { + materializeDurableToolResultProjection: async ({ toolCallId, projection }) => { assert.equal(toolCallId, 'call-1'); - assert.equal(output, result); + assert.deepEqual(projection, { + version: 1, + kind: 'content', + parts: [ + { + kind: 'artifact', + mediaType: 'image/png', + ref: result.ref, + }, + ], + }); return { type: 'text', value: 'materialized image' }; }, }); @@ -529,7 +543,10 @@ function makeRuntime( overrides: Partial< Pick< ToolRuntimeInput, - 'materializeDefaultToolResultOutput' | 'readExecutionBoundary' | 'spawnChildSession' | 'runId' + | 'materializeDurableToolResultProjection' + | 'readExecutionBoundary' + | 'spawnChildSession' + | 'runId' > > = {}, ): ToolRuntime { diff --git a/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts index 43a9c952f3..59fb6b8d89 100644 --- a/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts @@ -357,7 +357,7 @@ describe('ToolRuntime with real SQLite boundary', () => { impl: async () => ({ ok: true }), }; - await runtime.settleToolCall({ + const settlement = await runtime.settleToolCall({ tool: exclusive, turnId: 'turn-1', stepId: 'step-1', @@ -438,7 +438,7 @@ describe('ToolRuntime with real SQLite boundary', () => { const published: SessionEvent[] = []; - await runtime.settleToolCall({ + const settlement = await runtime.settleToolCall({ tool, turnId: 'turn-1', toolCallId: 'provider-call-1', @@ -471,6 +471,20 @@ describe('ToolRuntime with real SQLite boundary', () => { ); assert.equal((await store.readImmutableRuntimeEvents('session-1', 'run-1')).length, 3); + const response = events.find((event) => event.content?.kind === 'function_response'); + const durableProjection = + response?.content?.kind === 'function_response' + ? response.content.modelProjection + : undefined; + assert.deepEqual(durableProjection, { + version: 1, + kind: 'json', + value: { ok: true, text: 'contents' }, + }); + assert.deepEqual(settlement.modelOutput, { + type: 'json', + value: { ok: true, text: 'contents' }, + }); const context = invocationContext(); const memory = createSessionEventMapMemory(); const durableEvents = published.filter( @@ -482,10 +496,13 @@ describe('ToolRuntime with real SQLite boundary', () => { ); assert.deepEqual( mappedEvents, - events.filter( - (event) => - event.content?.kind === 'function_call' || event.content?.kind === 'function_response', - ), + events + .filter( + (event) => + event.content?.kind === 'function_call' || + event.content?.kind === 'function_response', + ) + .map((event) => JSON.parse(JSON.stringify(event))), ); assert.equal((await store.readRuntimeEvents('session-1', 'run-1')).length, 3); @@ -545,10 +562,13 @@ describe('ToolRuntime with real SQLite boundary', () => { const events = await store.readRuntimeEvents('session-1', 'run-1'); assert.deepEqual( mappedEvents, - events.filter( - (event) => - event.content?.kind === 'function_call' || event.content?.kind === 'function_response', - ), + events + .filter( + (event) => + event.content?.kind === 'function_call' || + event.content?.kind === 'function_response', + ) + .map((event) => JSON.parse(JSON.stringify(event))), ); assert.equal(events.length, 3); assert.equal(events[2]?.content?.kind, 'function_response'); diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 2364129d91..3fb04debd4 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -194,6 +194,8 @@ import { } from './ai-sdk-compaction.js'; import type { AiSdkCompactionCapabilities } from './ai-sdk-compaction-contract.js'; import type { ToolArtifactRecorder } from './tool-artifacts.js'; +import { durableProjectionToToolResultOutput } from './durable-tool-result-projection.js'; +import type { DurableToolResultProjection } from '@maka/core/durable-tool-result-projection'; import { openAiChatReasoningFieldFromProviderOptions } from './openai-chat-reasoning-transport.js'; import { RunTrace, type RunTraceRecorder } from './run-trace.js'; import { SandboxCommandError } from './sandbox/errors.js'; @@ -801,6 +803,8 @@ export interface AiSdkBackendInput extends AiSdkCompactionCapabilities { * Caller wires this to the session ArtifactStore; runtime never imports storage. */ readAttachmentBytes?: AttachmentByteReader; + /** Host-owned persistence for inline image parts before their Tool Result T2 commit. */ + persistDurableProjectionArtifact?: ToolRuntimeInput['persistDurableProjectionArtifact']; /** * Whether the selected model accepts image input. Only explicit true sends * image parts; false/unknown stay as text refs with a fallback note. @@ -1323,8 +1327,13 @@ export class AiSdkBackend implements AgentBackend { ...(identity.runId ? { runId: identity.runId } : {}), orchestrationMode: identity.orchestrationMode, ...(identity.invocationId ? { invocationId: identity.invocationId } : {}), - materializeDefaultToolResultOutput: ({ toolCallId, output }) => - this.materializeToolResultOutput(identity.scope().imageBudget, output, false, toolCallId), + materializeDurableToolResultProjection: ({ toolCallId, projection }) => + this.materializeDurableToolResultProjection( + identity.scope().imageBudget, + projection, + toolCallId, + ), + persistDurableProjectionArtifact: input.persistDurableProjectionArtifact, spawnChildSession: input.spawnChildSession, listChildAgents: input.listChildAgents, readChildAgentOutput: input.readChildAgentOutput, @@ -4413,6 +4422,62 @@ export class AiSdkBackend implements AgentBackend { }; } + private async materializeDurableToolResultProjection( + budget: ProviderImageBudget, + projection: DurableToolResultProjection, + decisionKey: string, + ): Promise { + if (projection.kind !== 'content') return durableProjectionToToolResultOutput(projection); + const value: Extract['value'] = []; + for (const [index, part] of projection.parts.entries()) { + if (part.kind === 'text') { + value.push({ type: 'text', text: part.text }); + continue; + } + if (this.input.supportsVision !== true) { + value.push({ + type: 'text', + text: 'Image was read, but the selected model does not support image input.', + }); + continue; + } + if (!this.input.readAttachmentBytes) { + value.push({ + type: 'text', + text: 'Image was read, but its stored bytes are unavailable.', + }); + continue; + } + let read: Awaited>; + try { + read = await this.input.readAttachmentBytes(part.ref); + } catch { + value.push({ + type: 'text', + text: 'Image could not be loaded from artifact storage: read_failed.', + }); + continue; + } + if (!read.ok) { + value.push({ + type: 'text', + text: `Image could not be loaded from artifact storage: ${read.reason}.`, + }); + continue; + } + if (!this.chargeImageBudget(budget, read.bytes.length, `${decisionKey}:artifact:${index}`)) { + value.push({ type: 'text', text: PROVIDER_IMAGE_BUDGET_EXCEEDED_MESSAGE }); + continue; + } + value.push({ + type: 'file', + data: { type: 'data', data: Buffer.from(read.bytes).toString('base64') }, + mediaType: part.mediaType, + }); + } + return { type: 'content', value }; + } + private async buildCurrentUserContent( budget: ProviderImageBudget, text: string, diff --git a/packages/runtime/src/session-event-runtime-mapper.ts b/packages/runtime/src/session-event-runtime-mapper.ts index 3044d110f9..b9acbfaa81 100644 --- a/packages/runtime/src/session-event-runtime-mapper.ts +++ b/packages/runtime/src/session-event-runtime-mapper.ts @@ -350,6 +350,9 @@ function mapBackendSessionEvent( ...(event.providerExecuted && event.providerOutput !== undefined ? { providerOutput: structuredClone(event.providerOutput) } : {}), + ...(event.modelProjection !== undefined + ? { modelProjection: event.modelProjection } + : {}), }, refs: { toolCallId: event.toolUseId, diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index a5d612072a..8a844cdebc 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -82,8 +82,20 @@ import { stableHash } from './request-shape.js'; import { classifyError } from './provider-error-classification.js'; import type { RunTraceLike } from './run-trace.js'; import { AwaitRegistry } from './await-registry.js'; -import { jsonValue } from './tool-result-output.js'; import type { ToolResultOutput } from './model-protocol.js'; +import { + durableProjectionToToolResultOutput, + durableProjectionHasArtifacts, + compatibilityToolResultProjection, + encodeDefaultDurableToolResultOutput, + encodeDurableToolResultOutput, + encodeDurableToolResultOutputWithArtifacts, +} from './durable-tool-result-projection.js'; +import { + DURABLE_TOOL_RESULT_PROJECTION_FAILURE, + type DurableProjectionArtifactRef, + type DurableToolResultProjection, +} from '@maka/core/durable-tool-result-projection'; import { buildToolOperationId, canonicalToolArgsHash, @@ -144,6 +156,7 @@ export interface ToolSettlement { export interface RawToolSettlement { result: unknown; providerError?: string; + modelProjection: DurableToolResultProjection; } export interface MakaTool

{ @@ -349,10 +362,16 @@ export interface ToolRuntimeInput { runId?: string; orchestrationMode?: OrchestrationMode; invocationId?: string; - materializeDefaultToolResultOutput?: (options: { + materializeDurableToolResultProjection?: (options: { toolCallId: string; - output: unknown; + projection: DurableToolResultProjection; }) => ToolResultOutput | PromiseLike; + persistDurableProjectionArtifact?: (input: { + turnId: string; + toolCallId: string; + bytes: Uint8Array; + mediaType: string; + }) => Promise; spawnChildSession?: (input: { parentRunId: string; parentTurnId: string; @@ -404,6 +423,7 @@ interface RuntimeManagedMutationOperationValue { readonly content: ToolResultContent; readonly isError: boolean; readonly durationMs: number; + readonly modelProjection: DurableToolResultProjection; }; } @@ -416,6 +436,7 @@ export interface RuntimeManagedMutationOperationProof { readonly content: ToolResultContent; readonly isError: boolean; readonly durationMs: number; + readonly modelProjection: DurableToolResultProjection; } export type RuntimeManagedMutationSettlement = @@ -446,12 +467,14 @@ interface DurableToolAttempt { commitOutcome( result: unknown, isError: boolean, + modelProjection: DurableToolResultProjection, durationMs?: number, ): Promise<{ id: string; operationId: string; ts: number }>; adoptCommittedOutcome( event: RuntimeEvent, result: ToolResultContent, isError: boolean, + modelProjection: DurableToolResultProjection, durationMs: number, ): { id: string; operationId: string; ts: number }; } @@ -710,27 +733,14 @@ export class ToolRuntime { */ async settleToolCall(call: ResolvedMakaToolCall): Promise { const settlement = await this.settleToolCallRaw(call); - const modelOutput = settlement.providerError - ? call.tool.providerTool?.kind === 'openai-apply-patch' - ? { - type: 'json' as const, - value: { status: 'failed' as const, output: settlement.providerError }, - } - : { type: 'error-text' as const, value: new Error(settlement.providerError).toString() } - : call.tool.toModelOutput - ? await call.tool.toModelOutput({ + const modelOutput = + durableProjectionHasArtifacts(settlement.modelProjection) && + this.input.materializeDurableToolResultProjection + ? await this.input.materializeDurableToolResultProjection({ toolCallId: call.toolCallId, - input: call.input, - output: settlement.result, + projection: settlement.modelProjection, }) - : this.input.materializeDefaultToolResultOutput - ? await this.input.materializeDefaultToolResultOutput({ - toolCallId: call.toolCallId, - output: settlement.result, - }) - : typeof settlement.result === 'string' - ? { type: 'text' as const, value: settlement.result } - : { type: 'json' as const, value: jsonValue(settlement.result) }; + : durableProjectionToToolResultOutput(settlement.modelProjection); return { result: settlement.result, modelOutput }; } @@ -754,6 +764,7 @@ export class ToolRuntime { } private async performToolSettlement(call: ResolvedMakaToolCall): Promise { + let modelProjection: DurableToolResultProjection | undefined; const result = await this.executeTool( call.tool, call.turnId, @@ -769,9 +780,71 @@ export class ToolRuntime { ...(call.providerOptions !== undefined ? { providerOptions: call.providerOptions } : {}), }, call.stepId, + (projection) => { + modelProjection = projection; + }, ); const providerError = providerToolErrorMessage(result); - return { result, ...(providerError ? { providerError } : {}) }; + if (modelProjection === undefined) { + const projected = this.projectToolResult( + call.tool, + call.turnId, + call.toolCallId, + call.input, + result, + ); + modelProjection = isPromiseLike(projected) ? await projected : projected; + } + return { result, modelProjection, ...(providerError ? { providerError } : {}) }; + } + + private projectToolResult( + tool: MakaTool, + turnId: string, + toolCallId: string, + input: unknown, + result: unknown, + ): DurableToolResultProjection | PromiseLike { + try { + const providerError = providerToolErrorMessage(result); + if (providerError) { + return encodeDurableToolResultOutput( + tool.providerTool?.kind === 'openai-apply-patch' + ? { + type: 'json' as const, + value: { status: 'failed' as const, output: providerError }, + } + : { type: 'error-text' as const, value: new Error(providerError).toString() }, + this.input.sessionId, + ); + } + if (!tool.toModelOutput) { + return encodeDefaultDurableToolResultOutput(result, this.input.sessionId); + } + const output = tool.toModelOutput({ toolCallId, input, output: result }); + const encode = (resolved: ToolResultOutput) => + encodeDurableToolResultOutputWithArtifacts( + resolved, + this.input.sessionId, + this.input.persistDurableProjectionArtifact + ? ({ bytes, mediaType }) => + this.input.persistDurableProjectionArtifact!({ + turnId, + toolCallId, + bytes, + mediaType, + }) + : undefined, + ); + return isPromiseLike(output) + ? Promise.resolve(output).then( + (resolved) => encode(resolved), + () => DURABLE_TOOL_RESULT_PROJECTION_FAILURE, + ) + : encode(output); + } catch { + return DURABLE_TOOL_RESULT_PROJECTION_FAILURE; + } } /** @@ -894,6 +967,7 @@ export class ToolRuntime { async writeSyntheticToolResult( toolUseId: string, turnId: string, + toolName: string, text: string, queue: DurableSessionEventSink, sandboxDenial?: SandboxDenialSignal, @@ -906,7 +980,7 @@ export class ToolRuntime { parentOperationId?: string; } = {}, attempt?: DurableToolAttempt, - ): Promise { + ): Promise { const content: ToolResultContent = { kind: 'text', text: formatSyntheticToolErrorText(text), @@ -924,7 +998,18 @@ export class ToolRuntime { // guards, where no attempt exists and no identity is owed. const durableAttempt = attempt ?? this.durableToolAttempts.get(durableAttemptKey(turnId, toolUseId)); - const durableOutcome = await durableAttempt?.commitOutcome(content, true); + const modelProjection = + compatibilityToolResultProjection( + { + kind: 'function_response', + id: toolUseId, + name: toolName, + result: content, + isError: true, + }, + this.input.sessionId, + ) ?? DURABLE_TOOL_RESULT_PROJECTION_FAILURE; + const durableOutcome = await durableAttempt?.commitOutcome(content, true, modelProjection); const msg: ToolResultMessage = { type: 'tool_result', id: this.input.newId(), @@ -945,8 +1030,10 @@ export class ToolRuntime { ...(durableOutcome ? { operationId: durableOutcome.operationId } : {}), isError: true, content, + modelProjection, ...activityIdentity, } satisfies ToolResultEvent); + return modelProjection; } private async executeTool( @@ -964,6 +1051,7 @@ export class ToolRuntime { maxResultBytes?: number; }, stepId?: string, + captureProjection?: (projection: DurableToolResultProjection) => void, ): Promise { const rawExecutionArgs = snapshotToolArgs(args); const sandboxBoundaryDecisionGeneration = this.sandboxBoundaryDecisionGeneration; @@ -1139,6 +1227,7 @@ export class ToolRuntime { await this.writeSyntheticToolResult( toolUseId, turnId, + tool.name, text, queue, undefined, @@ -1549,10 +1638,13 @@ export class ToolRuntime { const content = immutableSnapshot ? Object.freeze(coerceResultContent(result)) : coerceResultContent(result); + const projected = this.projectToolResult(tool, turnId, toolUseId, executionArgs, result); + const modelProjection = isPromiseLike(projected) ? await projected : projected; const outcome = { content, isError: deriveToolResultStatus(content, result) !== 'success', durationMs: this.input.now() - startedAt, + modelProjection, }; const value = { result, @@ -1593,6 +1685,7 @@ export class ToolRuntime { content: value.outcome.content, isError: value.outcome.isError, durationMs: value.outcome.durationMs, + modelProjection: value.outcome.modelProjection, }; } finally { if (operationLifecycle.state === 'running') { @@ -1664,7 +1757,8 @@ export class ToolRuntime { } const { result, outcome } = settledExecution.value; output.flush(); - const { content, durationMs } = outcome; + const { content, durationMs, modelProjection } = outcome; + captureProjection?.(modelProjection); // Keep the full provider-facing terminal classification. `isError` is // sufficient for the durable response envelope, but it intentionally // collapses `aborted` into an error bit and therefore cannot drive live @@ -1681,12 +1775,14 @@ export class ToolRuntime { settledExecution.durableOutcome, content, outcome.isError, + modelProjection, durationMs, ); } else { durableOutcome = await durableAttempt?.commitOutcome( content, outcome.isError, + modelProjection, durationMs, ); } @@ -1732,6 +1828,7 @@ export class ToolRuntime { ...(durableOutcome ? { operationId: durableOutcome.operationId } : {}), isError: toolResultStatus !== 'success', content, + modelProjection, durationMs, ...activityIdentity, } satisfies ToolResultEvent); @@ -1846,9 +1943,20 @@ export class ToolRuntime { ); } const durationMs = Math.max(0, this.input.now() - startedAt); + const terminalResult = this.errorReturn(terminalFailure.message); + const projected = this.projectToolResult( + tool, + turnId, + toolUseId, + executionArgs, + terminalResult, + ); + const modelProjection = isPromiseLike(projected) ? await projected : projected; + captureProjection?.(modelProjection); const durableOutcome = await durableAttempt?.commitOutcome( terminalFailure.content, true, + modelProjection, durationMs, ); const resultMsg: ToolResultMessage = { @@ -1872,6 +1980,7 @@ export class ToolRuntime { ...(durableOutcome ? { operationId: durableOutcome.operationId } : {}), isError: true, content: terminalFailure.content, + modelProjection, durationMs, ...activityIdentity, } satisfies ToolResultEvent); @@ -1902,7 +2011,7 @@ export class ToolRuntime { errorClass, ...(sandboxError ? { sandbox: sandboxError } : {}), }); - return this.errorReturn(terminalFailure.message); + return terminalResult; } const msg = err instanceof ToolResultLimitError @@ -1912,9 +2021,10 @@ export class ToolRuntime { : uncertainOutcome ? `outcome_unknown: ${formatSyntheticToolErrorText(err)}` : formatSyntheticToolErrorText(err); - await this.writeSyntheticToolResult( + const modelProjection = await this.writeSyntheticToolResult( toolUseId, turnId, + tool.name, msg, queue, sandboxDenialSignalFromError(err), @@ -1923,6 +2033,7 @@ export class ToolRuntime { activityIdentity, durableAttempt, ); + captureProjection?.(modelProjection); this.input.recordToolInvocation?.({ sessionId: this.input.sessionId, turnId, @@ -2049,6 +2160,7 @@ export class ToolRuntime { actions: { toolDispatch: { protocol: TOOL_BOUNDARY_PROTOCOL_V1, + resultProjectionVersion: 1, operationId, providerToolCallId: input.startEvent.toolUseId, toolName: input.tool.name, @@ -2109,6 +2221,7 @@ export class ToolRuntime { const buildResponseEvent = ( result: unknown, isError: boolean, + modelProjection: DurableToolResultProjection, durationMs: number | undefined, ts: number, ): RuntimeEvent => ({ @@ -2129,6 +2242,7 @@ export class ToolRuntime { name: input.tool.name, result, ...(isError ? { isError: true } : {}), + modelProjection, }, refs: { operationId, @@ -2146,9 +2260,15 @@ export class ToolRuntime { return { operationId, responseEventId: `${operationId}_response`, - commitOutcome: async (result, isError, durationMs) => { + commitOutcome: async (result, isError, modelProjection, durationMs) => { if (committedOutcome) return committedOutcome; - const responseEvent = buildResponseEvent(result, isError, durationMs, this.input.now()); + const responseEvent = buildResponseEvent( + result, + isError, + modelProjection, + durationMs, + this.input.now(), + ); try { await sink.commitToolOutcome({ operationId, @@ -2169,9 +2289,9 @@ export class ToolRuntime { ); return committedOutcome; }, - adoptCommittedOutcome: (event, result, isError, durationMs) => { + adoptCommittedOutcome: (event, result, isError, modelProjection, durationMs) => { if (committedOutcome) return committedOutcome; - const expected = buildResponseEvent(result, isError, durationMs, event.ts); + const expected = buildResponseEvent(result, isError, modelProjection, durationMs, event.ts); if (!Number.isFinite(event.ts) || !isDeepStrictEqual(event, expected)) { throw new RuntimeCommitBoundaryError( 'T2', @@ -3249,6 +3369,7 @@ function normalizeManagedMutationSettlement( const expectedError = kind === 'operation_failed_no_effect_committed'; if ( response?.kind !== 'function_response' || + response.modelProjection === undefined || (expectedError ? response.isError !== true : response.isError === true) ) { throw new Error('Managed no-effect settlement has the wrong durable outcome state'); @@ -3257,6 +3378,7 @@ function normalizeManagedMutationSettlement( const outcome = Object.freeze({ content, isError: expectedError, + modelProjection: response.modelProjection, durationMs: typeof durableOutcome.actions?.stateDelta?.durationMs === 'number' ? durableOutcome.actions.stateDelta.durationMs @@ -3709,6 +3831,15 @@ function providerToolErrorMessage(output: unknown): string | undefined { return record.error; } +function isPromiseLike(value: T | PromiseLike): value is PromiseLike { + return ( + typeof value === 'object' && + value !== null && + 'then' in value && + typeof (value as { then?: unknown }).then === 'function' + ); +} + function summarizeArgs(toolName: string, args: unknown): string { const projected = toolName === 'WebSearch' diff --git a/packages/storage/src/__tests__/sqlite-runtime-store.test.ts b/packages/storage/src/__tests__/sqlite-runtime-store.test.ts index be1d955a10..3fa3060403 100644 --- a/packages/storage/src/__tests__/sqlite-runtime-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-runtime-store.test.ts @@ -420,8 +420,16 @@ describe('SqliteRuntimeStore', () => { it('commits function_response, outcome journal fact, and projection atomically in T2', async () => { await withStore(async (store) => { - await commitPrepared(store); - const outcome = functionResponseEvent(); + await commitPrepared(store, { resultProjectionVersion: 1 }); + const outcome = functionResponseEvent({ + content: { + kind: 'function_response', + id: 'provider-call-1', + name: 'Read', + result: 'private execution contents', + modelProjection: { version: 1, kind: 'text', text: 'bounded model contents' }, + }, + }); const result = await store.commitToolOutcome({ operationId: 'operation-1', @@ -434,7 +442,19 @@ describe('SqliteRuntimeStore', () => { assert.equal(result.runtimeEventSeq, 3); assert.deepEqual(await store.readRuntimeEvents('session-1', 'run-1'), [ functionCallEvent(), - toolDispatchEvent(), + toolDispatchEvent({ + actions: { + toolDispatch: { + protocol: 't1_after_preflight_v1', + operationId: 'operation-1', + providerToolCallId: 'provider-call-1', + toolName: 'Read', + canonicalArgsHash: READ_ARGS_HASH, + recoveryMode: 'replay_safe', + resultProjectionVersion: 1, + }, + }, + }), outcome, ]); assert.equal((await store.readImmutableRuntimeEvents('session-1', 'run-1')).length, 3); @@ -461,6 +481,32 @@ describe('SqliteRuntimeStore', () => { }); }); + it('keeps projected T2 prepared when its atomic model projection is missing', async () => { + await withStore(async (store) => { + await commitPrepared(store, { resultProjectionVersion: 1 }); + + await assert.rejects( + store.commitToolOutcome({ + operationId: 'operation-1', + journalEventId: 'operation-1_outcome', + runtimeEvent: functionResponseEvent(), + committedAt: 20, + }), + /requires its durable model projection/, + ); + + assert.deepEqual( + (await store.readRuntimeEvents('session-1', 'run-1')).map((event) => event.id), + ['call-event-1', 'dispatch-event-1'], + ); + assert.equal((await store.readToolOperation('operation-1'))?.currentState, 'prepared'); + assert.deepEqual( + (await store.readToolJournal('operation-1')).map((event) => event.state), + ['prepared'], + ); + }); + }); + it('rolls back T2 without hiding the previously committed prepared boundary', async () => { await withStore(async (store, _dbPath, setFailpoint) => { await commitPrepared(store); @@ -2069,12 +2115,26 @@ function toolDispatchEvent(overrides: Partial = {}): RuntimeEvent }; } -function commitPrepared(store: Store) { +function commitPrepared(store: Store, options: { resultProjectionVersion?: 1 } = {}) { return store.commitToolPrepared({ operationId: 'operation-1', journalEventId: 'operation-1_prepared', runtimeEvent: functionCallEvent(), - dispatchRuntimeEvent: toolDispatchEvent(), + dispatchRuntimeEvent: toolDispatchEvent({ + actions: { + toolDispatch: { + protocol: 't1_after_preflight_v1', + operationId: 'operation-1', + providerToolCallId: 'provider-call-1', + toolName: 'Read', + canonicalArgsHash: READ_ARGS_HASH, + recoveryMode: 'replay_safe', + ...(options.resultProjectionVersion !== undefined + ? { resultProjectionVersion: options.resultProjectionVersion } + : {}), + }, + }, + }), providerToolCallId: 'provider-call-1', toolName: 'Read', canonicalArgsHash: READ_ARGS_HASH, diff --git a/packages/storage/src/sqlite-runtime-store.ts b/packages/storage/src/sqlite-runtime-store.ts index c060f26833..c2c459e46c 100644 --- a/packages/storage/src/sqlite-runtime-store.ts +++ b/packages/storage/src/sqlite-runtime-store.ts @@ -2799,6 +2799,13 @@ export class SqliteRuntimeStore const dispatchEvent = dispatchJson ? decodeRuntimeEvent(JSON.parse(dispatchJson) as unknown) : undefined; + if ( + dispatchEvent?.actions?.toolDispatch?.resultProjectionVersion === 1 && + input.runtimeEvent.content?.kind === 'function_response' && + input.runtimeEvent.content.modelProjection === undefined + ) { + throw new Error('Projected Tool Result T2 requires its durable model projection'); + } if (dispatchEvent?.actions?.toolDispatch?.managedMutation) { const reservation = this.db .prepare(` From 53a929061f7ca3a3779547199e7cb17764e9e239 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 07:56:44 +0800 Subject: [PATCH 04/30] refactor(runtime): replay effective tool result projections Generated-by: Codex --- .../overflow-reactive-recovery.test.ts | 2 +- .../pre-dispatch-refusal-ledger.test.ts | 15 ++++- .../runtime-event-read-model.test.ts | 50 +++++++++++++++ .../tool-runtime-sqlite-boundary.test.ts | 9 +++ packages/runtime/src/ai-sdk-backend.ts | 22 ++++--- packages/runtime/src/model-history.ts | 63 +++++-------------- .../src/session-event-runtime-mapper.ts | 35 ++++++----- 7 files changed, 126 insertions(+), 70 deletions(-) diff --git a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts index 510795351b..b726de0dd3 100644 --- a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts +++ b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts @@ -574,7 +574,7 @@ function buildReactiveFixture(options: ReactiveFixtureOptions): ReactiveFixture ref: { kind: 'session_file' as const, sessionId: 'session-1', - relativePath: 'live.png', + relativePath: 'live_image', }, }; } diff --git a/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts b/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts index a85749edf0..19292e2d96 100644 --- a/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts +++ b/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts @@ -36,6 +36,8 @@ import { } from '../session-event-runtime-mapper.js'; import type { RuntimeEventMapContext } from '../session-event-runtime-mapper.js'; import type { RuntimeCommitSink } from '../runtime-commit-sink.js'; +import { buildRuntimeEventModelReplayPlan } from '../model-history.js'; +import { durableProjectionToToolResultOutput } from '../durable-tool-result-projection.js'; import { LOOP_GATE_IDENTICAL_THRESHOLD, type MakaTool, type ToolRuntime } from '../tool-runtime.js'; import { createTestToolRuntime } from './execution-boundary-test-helpers.js'; @@ -372,7 +374,7 @@ test('client-capability refusal carries actionable bypass metadata', async () => test('arguments the schema rejects leave a matched call/response pair on the generic lane', async () => { const h = harness(); - const { result } = await settle(h, swarmTool, { + const settlement = await settle(h, swarmTool, { items: [ { item_id: 'a', task: 'one', subagent_id: 'reviewer' }, { item_id: 'b', task: 'two', subagent_id: 'reviewer' }, @@ -383,7 +385,7 @@ test('arguments the schema rejects leave a matched call/response pair on the gen // The refusal still reaches the model, unchanged. assert.match( - (result as { error?: string }).error ?? '', + (settlement.result as { error?: string }).error ?? '', /Tool "exclusive_batch" arguments failed validation/, ); @@ -409,6 +411,15 @@ test('arguments the schema rejects leave a matched call/response pair on the gen assert.equal(operation?.callEvent?.content?.kind, 'function_call'); assert.equal(operation?.responseEvent?.content?.kind, 'function_response'); assert.equal(operation?.dispatchEvent, undefined); + const replayResult = buildRuntimeEventModelReplayPlan(ledger).items.find( + (item) => item.kind === 'tool_result', + ); + assert.deepEqual( + settlement.modelOutput, + replayResult?.modelProjection + ? durableProjectionToToolResultOutput(replayResult.modelProjection) + : undefined, + ); }); test('a dispatched call still claims the T1 lane and settles through the commit sink', async () => { diff --git a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts index 9859265b55..b987324dbf 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -651,6 +651,56 @@ describe('projectRuntimeEventsToStoredMessages', () => { expect(replay.diagnostics).toEqual([]); }); + test('reduces live, next-turn, and cold-restart Tool Results from one durable projection', () => { + const events = [ + ev({ + id: 'evt-projected-call', + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'projected-1', + name: 'PrivateTool', + args: {}, + }, + }), + ev({ + id: 'evt-projected-result', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'projected-1', + name: 'PrivateTool', + result: { secretExecutionFact: 'must not reach the model' }, + modelProjection: { + version: 1, + kind: 'text', + text: 'stable model fact', + }, + }, + }), + ]; + + const results = [ + buildRuntimeEventModelReplayPlan(events), + buildRuntimeEventModelReplayPlan([...events]), + buildRuntimeEventModelReplayPlan(JSON.parse(JSON.stringify(events)) as RuntimeEvent[]), + ].map((plan) => plan.items.find((item) => item.kind === 'tool_result')); + const projections = results.map((result) => result?.modelProjection); + + expect(projections).toEqual([ + { version: 1, kind: 'text', text: 'stable model fact' }, + { version: 1, kind: 'text', text: 'stable model fact' }, + { version: 1, kind: 'text', text: 'stable model fact' }, + ]); + expect(results.map((result) => result?.modelProjectionSource)).toEqual([ + 'durable', + 'durable', + 'durable', + ]); + }); + test('folds retired permission modes while projecting persisted tool results', () => { const out = projectRuntimeEventsToStoredMessages( [ diff --git a/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts index 59fb6b8d89..a8b3f97a65 100644 --- a/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts @@ -485,6 +485,15 @@ describe('ToolRuntime with real SQLite boundary', () => { type: 'json', value: { ok: true, text: 'contents' }, }); + const nextTurnProjection = buildRuntimeEventModelReplayPlan(events).items.find( + (item) => item.kind === 'tool_result', + )?.modelProjection; + const coldRestartProjection = buildRuntimeEventModelReplayPlan( + JSON.parse(JSON.stringify(events)), + ).items.find((item) => item.kind === 'tool_result')?.modelProjection; + assert.deepEqual(nextTurnProjection, durableProjection); + assert.deepEqual(coldRestartProjection, durableProjection); + const context = invocationContext(); const memory = createSessionEventMapMemory(); const durableEvents = published.filter( diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 3fb04debd4..1040c989c7 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -3863,14 +3863,22 @@ export class AiSdkBackend implements AgentBackend { result: ToolResultItem, toolName: string, ): Promise => { + const settledOutput = settledModelOutputs?.get(result.toolCallId); const output = - settledModelOutputs?.get(result.toolCallId) ?? - (await this.materializeToolResultOutput( - budget, - result.output, - result.isError, - `runtime-event:${result.eventId}:tool-result`, - )); + result.modelProjection && + (result.modelProjectionSource === 'durable' || settledOutput === undefined) + ? await this.materializeDurableToolResultProjection( + budget, + result.modelProjection, + `runtime-event:${result.eventId}:tool-result`, + ) + : (settledOutput ?? + (await this.materializeToolResultOutput( + budget, + result.output, + result.isError, + `runtime-event:${result.eventId}:tool-result`, + ))); if (toolName !== 'apply_patch') return output; return result.isError ? nativeApplyPatchFailureOutput(output) : output; }; diff --git a/packages/runtime/src/model-history.ts b/packages/runtime/src/model-history.ts index 53332e4343..568d375b05 100644 --- a/packages/runtime/src/model-history.ts +++ b/packages/runtime/src/model-history.ts @@ -61,16 +61,11 @@ import { type RuntimeEventContent, type RuntimeEventRole, } from '@maka/core/runtime-event'; -import { withToolResultArchiveResourceRef } from './tool-result-archive.js'; import { formatAttachmentResourceRef } from '@maka/core/attachments'; -import { decodeCanonicalShellToolResultContent } from '@maka/core/shell-run-result'; -import { markPersisted } from '@maka/core/persisted-value'; -import { decodePersistedToolResultContent } from '@maka/core/tool-result-record-schema'; -import type { ToolResultContent } from '@maka/core/events'; import type { AttachmentRef, QuoteRef } from '@maka/core/events'; import type { ModelMessage, UserContent, UserModelMessage } from './model-protocol.js'; -import { projectBashToolResultForModel } from './bash-model-output.js'; -import { projectFileWriteToolResultForModel } from './file-tool-model-output.js'; +import { decodeEffectiveToolResultProjection } from './durable-tool-result-projection.js'; +import type { DurableToolResultProjection } from '@maka/core/durable-tool-result-projection'; export const PROVIDER_REPLAY_PROJECTION_VERSION = 1; @@ -178,6 +173,8 @@ export type RuntimeEventModelReplayItem = toolName: string; output: unknown; isError: boolean; + modelProjection?: DurableToolResultProjection; + modelProjectionSource?: 'durable' | 'compatibility'; providerExecuted?: boolean; eventId: string; ts: number; @@ -610,47 +607,21 @@ export function buildRuntimeEventModelReplayPlan( ); continue; } - const usesProviderOutput = - event.content.providerExecuted && event.content.providerOutput !== undefined; - let invalidResultMessage: string | undefined; - let normalizedResult: unknown = usesProviderOutput - ? event.content.providerOutput - : withToolResultArchiveResourceRef(event.content.result); - if (!usesProviderOutput && isRetiredExploreAgentResult(normalizedResult)) { - try { - normalizedResult = decodePersistedToolResultContent( - markPersisted(normalizedResult), - ); - } catch { - invalidResultMessage = 'function_response contains an invalid retired tool result'; - } - } - if (!invalidResultMessage) { - const shellResult = decodeCanonicalShellToolResultContent(normalizedResult); - if (shellResult.state === 'invalid') { - invalidResultMessage = 'function_response contains an invalid shell tool result'; - } else if (shellResult.state === 'valid') { - normalizedResult = shellResult.content; - } - } - if (!invalidResultMessage && event.content.name === 'Bash') { - normalizedResult = projectBashToolResultForModel(normalizedResult); - } else if (!invalidResultMessage) { - normalizedResult = projectFileWriteToolResultForModel( - event.content.name, - normalizedResult, - ); - } - if (invalidResultMessage) { + const effective = decodeEffectiveToolResultProjection(event.content, event.sessionId); + if (effective.kind === 'invalid_legacy') { const call = callsById.get(event.content.id); if (call) { const callIndex = items.indexOf(call.item); if (callIndex >= 0) items.splice(callIndex, 1); callsById.delete(event.content.id); } - diagnostics.push(diagnostic(event, 'unsupported_content', invalidResultMessage)); + diagnostics.push(diagnostic(event, 'unsupported_content', effective.message)); continue; } + const normalizedResult = + effective.kind === 'provider_native' || effective.kind === 'legacy_output' + ? effective.output + : effective.legacyOutput; const call = callsById.get(event.content.id); if (!call) { diagnostics.push( @@ -683,6 +654,12 @@ export function buildRuntimeEventModelReplayPlan( toolCallId: event.content.id, toolName: event.content.name, output: normalizedResult, + ...(effective.kind === 'projection' + ? { + modelProjection: effective.projection, + modelProjectionSource: effective.source, + } + : {}), isError: event.content.isError === true, ...(event.content.providerExecuted !== undefined ? { providerExecuted: event.content.providerExecuted } @@ -749,12 +726,6 @@ export function buildRuntimeEventModelReplayPlan( }; } -function isRetiredExploreAgentResult(value: unknown): boolean { - return ( - typeof value === 'object' && value !== null && 'kind' in value && value.kind === 'explore_agent' - ); -} - /** * Convert projected RuntimeEvent history into the current AI SDK text-only * message shape. Tool/function and thinking entries are intentionally skipped. diff --git a/packages/runtime/src/session-event-runtime-mapper.ts b/packages/runtime/src/session-event-runtime-mapper.ts index b9acbfaa81..63e251c84e 100644 --- a/packages/runtime/src/session-event-runtime-mapper.ts +++ b/packages/runtime/src/session-event-runtime-mapper.ts @@ -34,6 +34,7 @@ import { import type { RuntimeEvent, RuntimeEventStatus } from '@maka/core/runtime-event'; import type { BackendSessionEvent } from '@maka/core/backend-types'; +import { compatibilityToolResultProjection } from './durable-tool-result-projection.js'; export interface RuntimeEventMapContext { readonly sessionId: string; @@ -332,6 +333,24 @@ function mapBackendSessionEvent( }; case 'tool_result': { const name = memory.toolNameByUseId.get(event.toolUseId) ?? ''; + const content = { + kind: 'function_response' as const, + id: event.toolUseId, + name, + result: event.content, + ...(event.isError ? { isError: true as const } : {}), + ...(event.providerExecuted !== undefined + ? { providerExecuted: event.providerExecuted } + : {}), + ...(event.providerExecuted && event.providerOutput !== undefined + ? { providerOutput: structuredClone(event.providerOutput) } + : {}), + }; + const modelProjection = + event.modelProjection ?? + (event.operationId === undefined + ? compatibilityToolResultProjection(content, ctx.sessionId) + : undefined); const ev: RuntimeEvent = { ...base, role: 'tool', @@ -339,20 +358,8 @@ function mapBackendSessionEvent( ...(event.origin !== undefined ? { origin: event.origin } : {}), ...(event.modelVisibility !== undefined ? { modelVisibility: event.modelVisibility } : {}), content: { - kind: 'function_response', - id: event.toolUseId, - name, - result: event.content, - ...(event.isError ? { isError: true } : {}), - ...(event.providerExecuted !== undefined - ? { providerExecuted: event.providerExecuted } - : {}), - ...(event.providerExecuted && event.providerOutput !== undefined - ? { providerOutput: structuredClone(event.providerOutput) } - : {}), - ...(event.modelProjection !== undefined - ? { modelProjection: event.modelProjection } - : {}), + ...content, + ...(modelProjection ? { modelProjection } : {}), }, refs: { toolCallId: event.toolUseId, From 8956bfaa313051346548055a578992f24ea60332 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 07:57:07 +0800 Subject: [PATCH 05/30] feat(runtime-host): persist projected artifacts and recoveries Generated-by: Codex --- .../client-capability-recovery.test.ts | 7 ++ .../src/server/client-capability-recovery.ts | 17 ++-- .../src/server/execution-model-composition.ts | 10 +++ .../__tests__/computer-use-model-loop.test.ts | 1 + .../computer-use-provider-protocol.test.ts | 6 ++ .../execution-boundary-test-helpers.ts | 31 ++++++- .../src/__tests__/recovery-resolver.test.ts | 82 +++++++++++++++++++ packages/runtime/src/recovery-resolver.ts | 32 ++++---- 8 files changed, 163 insertions(+), 23 deletions(-) diff --git a/packages/runtime-host/src/__tests__/client-capability-recovery.test.ts b/packages/runtime-host/src/__tests__/client-capability-recovery.test.ts index badfba53e2..19874cce2c 100644 --- a/packages/runtime-host/src/__tests__/client-capability-recovery.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-recovery.test.ts @@ -60,6 +60,12 @@ test('successor recovery durably settles dispatched Client Capabilities as outco }, }, isError: true, + modelProjection: { + version: 1, + kind: 'text', + text: 'Error: outcome_unknown: the Host restarted after dispatching this Client Capability call. The client-side effect may have happened; do not retry it automatically.', + isError: true, + }, }, refs: { operationId: 'capability-operation', @@ -132,6 +138,7 @@ async function prepare( toolName: 'client_tool', canonicalArgsHash, recoveryMode, + resultProjectionVersion: 1, }, }, refs: { operationId, toolCallId: providerToolCallId }, diff --git a/packages/runtime-host/src/server/client-capability-recovery.ts b/packages/runtime-host/src/server/client-capability-recovery.ts index 1ad74e8402..43aec6f104 100644 --- a/packages/runtime-host/src/server/client-capability-recovery.ts +++ b/packages/runtime-host/src/server/client-capability-recovery.ts @@ -19,6 +19,7 @@ import type { ToolResultContent } from '@maka/core/events'; import type { RuntimeEvent } from '@maka/core/runtime-event'; +import { compatibilityToolResultProjection } from '@maka/runtime/durable-tool-result-projection'; import type { ExecutionRuntimeEventWriter } from '@maka/storage/execution-stores'; const OUTCOME_UNKNOWN_TEXT = @@ -43,6 +44,14 @@ export async function recoverClientCapabilityOutcomes( retrySafe: false, }, } as const satisfies ToolResultContent; + const responseContent = { + kind: 'function_response' as const, + id: operation.providerToolCallId, + name: operation.toolName, + result, + isError: true as const, + }; + const modelProjection = compatibilityToolResultProjection(responseContent, sessionId); const runtimeEvent: RuntimeEvent = { id: `${operation.operationId}_response`, invocationId: operation.invocationId, @@ -53,13 +62,7 @@ export async function recoverClientCapabilityOutcomes( partial: false, role: 'tool', author: 'tool', - content: { - kind: 'function_response', - id: operation.providerToolCallId, - name: operation.toolName, - result, - isError: true, - }, + content: { ...responseContent, ...(modelProjection ? { modelProjection } : {}) }, refs: { operationId: operation.operationId, toolCallId: operation.providerToolCallId, diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index 454aa2338a..3e2b65752f 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -48,6 +48,7 @@ import { type BackendFactoryContext } from '@maka/runtime/session-manager'; import { type RuntimeCommitSink } from '@maka/runtime/runtime-commit-sink'; import { createAttachmentByteReader, + createReadImageSnapshotter, persistProviderRequestCaptureArtifact, type InteractiveArtifactStoreWriter, } from '@maka/storage/artifact-stores'; @@ -332,6 +333,7 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom ); } : undefined; + const persistProjectionImage = createReadImageSnapshotter(input.artifacts); try { return new HostAiSdkBackend( @@ -398,6 +400,14 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom ? { readImageSnapshotsUnavailable: true } : {}), }), + persistDurableProjectionArtifact: ({ turnId, bytes, mediaType }) => + persistProjectionImage({ + sessionId: input.context.sessionId, + turnId, + name: 'Tool Result image', + bytes, + mimeType: mediaType, + }), recordToolArtifacts: input.executionArtifacts.recordToolArtifacts, toolResultArchive: input.executionArtifacts.toolResultArchive, ...(!input.context.tools && diff --git a/packages/runtime/src/__tests__/computer-use-model-loop.test.ts b/packages/runtime/src/__tests__/computer-use-model-loop.test.ts index 2e071cad89..3ba7e3c99c 100644 --- a/packages/runtime/src/__tests__/computer-use-model-loop.test.ts +++ b/packages/runtime/src/__tests__/computer-use-model-loop.test.ts @@ -394,6 +394,7 @@ function createRuntime(input: { modelId: 'mock-computer-model', modelFactory: () => input.model, tools: [input.computerTool], + testProjectionArtifacts: true, ...(input.durable ? { loadTurnRuntimeEvents: input.durable.loadTurnRuntimeEvents } : {}), newId: idGenerator(), now: monotonicClock(), 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 cbac5ac106..e928514689 100644 --- a/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts +++ b/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts @@ -150,6 +150,7 @@ describe('Anthropic-compatible Computer Use product loops', () => { provider.expectedWireOutputLimit, ); const runtime = createTestAiSdkBackend({ + testProjectionArtifacts: true, sessionId, header: header(provider.providerType, provider.modelId), appendMessage: async () => {}, @@ -275,6 +276,7 @@ describe('Anthropic-compatible Computer Use product loops', () => { }); const events: SessionEvent[] = []; const runtime = createTestAiSdkBackend({ + testProjectionArtifacts: true, sessionId, header: header(provider.providerType, provider.modelId), appendMessage: async () => {}, @@ -378,6 +380,7 @@ describe('OpenAI-compatible product loops', () => { 'openai-chat', ); const runtime = createTestAiSdkBackend({ + testProjectionArtifacts: true, sessionId, header: header(provider.providerType, provider.modelId), appendMessage: async () => {}, @@ -494,6 +497,7 @@ describe('OpenAI-compatible product loops', () => { now: monotonicClock(), }); const runtime = createTestAiSdkBackend({ + testProjectionArtifacts: true, sessionId, header: header('kimi-coding-plan', 'k3'), appendMessage: async () => {}, @@ -576,6 +580,7 @@ describe('OpenAI-compatible product loops', () => { ); const createRuntime = () => createTestAiSdkBackend({ + testProjectionArtifacts: true, sessionId, header: header('kimi-coding-plan', 'k3'), appendMessage: async (message) => { @@ -695,6 +700,7 @@ describe('OpenAI-compatible product loops', () => { ); const events: SessionEvent[] = []; const runtime = createTestAiSdkBackend({ + testProjectionArtifacts: true, sessionId, header: header('kimi-coding-plan', 'k3'), appendMessage: async () => {}, diff --git a/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts b/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts index 65900de151..ef48ee2fba 100644 --- a/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts +++ b/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts @@ -31,12 +31,39 @@ export const readExternalExecutionBoundary: AiSdkBackendInput['readExecutionBoun createExternalExecutionBoundary(); type TestAiSdkBackendInput = Omit & - Partial>; + Partial> & { + testProjectionArtifacts?: boolean; + }; export function createTestAiSdkBackend(input: TestAiSdkBackendInput): AiSdkBackend { + const { testProjectionArtifacts, ...backendInput } = input; + const artifacts = new Map(); + let nextArtifactId = 0; return new AiSdkBackend({ readExecutionBoundary: readExternalExecutionBoundary, - ...input, + ...backendInput, + ...(testProjectionArtifacts + ? { + persistDurableProjectionArtifact: async ({ bytes }: { bytes: Uint8Array }) => { + const relativePath = `artifact-${++nextArtifactId}`; + artifacts.set(relativePath, bytes.slice()); + return { + kind: 'session_file' as const, + sessionId: input.sessionId, + relativePath, + }; + }, + readAttachmentBytes: + input.readAttachmentBytes ?? + (async (ref) => { + const bytes = + ref.kind === 'session_file' ? artifacts.get(ref.relativePath) : undefined; + return bytes + ? { ok: true as const, bytes: bytes.slice() } + : { ok: false as const, reason: 'not_found' as const }; + }), + } + : {}), }); } diff --git a/packages/runtime/src/__tests__/recovery-resolver.test.ts b/packages/runtime/src/__tests__/recovery-resolver.test.ts index dd975fcd74..7fd2c4c5a8 100644 --- a/packages/runtime/src/__tests__/recovery-resolver.test.ts +++ b/packages/runtime/src/__tests__/recovery-resolver.test.ts @@ -18,9 +18,13 @@ */ import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, it } from 'node:test'; import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; import type { RuntimeEvent } from '@maka/core/runtime-event'; +import { createSqliteRuntimeStore } from '@maka/storage/sqlite-runtime-store'; import { buildInterruptedCodeModeOutcomeCommits, resolveRuntimeRecovery, @@ -139,6 +143,80 @@ describe('RecoveryResolver', () => { }, }, ); + assert.deepEqual( + commits[0]?.runtimeEvent.content?.kind === 'function_response' + ? commits[0].runtimeEvent.content.modelProjection + : undefined, + { + version: 1, + kind: 'json', + value: { + kind: 'json', + value: { + kind: 'code_mode', + status: 'interrupted', + message: 'Code Mode execution was interrupted by runtime recovery.', + }, + }, + isError: true, + }, + ); + }); + + it('commits a projected recovery outcome through the projection-aware SQLite T2 gate', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-recovery-projection-')); + const store = createSqliteRuntimeStore(join(root, 'runtime.sqlite')); + try { + const call = event({ + id: 'outer-call', + role: 'model', + author: 'agent', + origin: 'provider', + modelVisibility: 'visible', + content: { kind: 'function_call', id: 'exec-1', name: 'exec', args: { code: 'work()' } }, + refs: { operationId: 'outer-op', toolCallId: 'exec-1' }, + }); + const dispatch = dispatchFor({ + id: 'outer-dispatch', + operationId: 'outer-op', + toolCallId: 'exec-1', + toolName: 'exec', + args: { code: 'work()' }, + resultProjectionVersion: 1, + }); + await store.commitToolPrepared({ + operationId: 'outer-op', + journalEventId: 'outer-op_prepared', + runtimeEvent: call, + dispatchRuntimeEvent: dispatch, + providerToolCallId: 'exec-1', + toolName: 'exec', + canonicalArgsHash: canonicalToolArgsHash('exec', { code: 'work()' }), + recoveryMode: 'never_auto_retry', + committedAt: 10, + }); + + const [commit] = buildInterruptedCodeModeOutcomeCommits( + await store.readImmutableRuntimeEvents('session-1', 'run-1'), + 50, + 'code_mode', + ); + assert.ok(commit); + await store.commitToolOutcome(commit); + + assert.equal((await store.readToolOperation('outer-op'))?.currentState, 'outcome_committed'); + const response = (await store.readImmutableRuntimeEvents('session-1', 'run-1')).at(-1); + assert.equal(response?.content?.kind, 'function_response'); + assert.equal( + response?.content?.kind === 'function_response' + ? response.content.modelProjection?.version + : undefined, + 1, + ); + } finally { + store.close(); + await rm(root, { recursive: true, force: true }); + } }); it('does not infer Code Mode recovery from a custom direct exec name', () => { @@ -399,6 +477,7 @@ function dispatchFor(input: { modelVisibility?: 'visible' | 'hidden'; parentOperationId?: string; parentToolCallId?: string; + resultProjectionVersion?: 1; }): RuntimeEvent { return event({ id: input.id, @@ -412,6 +491,9 @@ function dispatchFor(input: { toolName: input.toolName, canonicalArgsHash: canonicalToolArgsHash(input.toolName, input.args), recoveryMode: 'never_auto_retry', + ...(input.resultProjectionVersion !== undefined + ? { resultProjectionVersion: input.resultProjectionVersion } + : {}), }, }, refs: { diff --git a/packages/runtime/src/recovery-resolver.ts b/packages/runtime/src/recovery-resolver.ts index 925944c8c0..aac21f7c78 100644 --- a/packages/runtime/src/recovery-resolver.ts +++ b/packages/runtime/src/recovery-resolver.ts @@ -30,6 +30,7 @@ import { import { interpretScannedToolRecovery } from '@maka/core/tool-recovery-bundle'; import type { ToolOutcomeCommit } from './runtime-commit-sink.js'; import type { ToolMode } from '@maka/core/tool-mode'; +import { compatibilityToolResultProjection } from './durable-tool-result-projection.js'; export type ToolRecoveryDecisionStatus = | 'completed' @@ -151,6 +152,22 @@ export function buildInterruptedCodeModeOutcomeCommits( ) { return []; } + const result = { + kind: 'json' as const, + value: { + kind: 'code_mode' as const, + status: 'interrupted' as const, + message: 'Code Mode execution was interrupted by runtime recovery.', + }, + }; + const responseContent = { + kind: 'function_response' as const, + id: call.id, + name: call.name, + result, + isError: true as const, + }; + const modelProjection = compatibilityToolResultProjection(responseContent, callEvent.sessionId); const runtimeEvent: RuntimeEvent = { id: `${decision.operationId}_response`, invocationId: callEvent.invocationId, @@ -163,20 +180,7 @@ export function buildInterruptedCodeModeOutcomeCommits( author: 'tool', origin: 'provider', modelVisibility: 'visible', - content: { - kind: 'function_response', - id: call.id, - name: call.name, - result: { - kind: 'json', - value: { - kind: 'code_mode', - status: 'interrupted', - message: 'Code Mode execution was interrupted by runtime recovery.', - }, - }, - isError: true, - }, + content: { ...responseContent, ...(modelProjection ? { modelProjection } : {}) }, refs: { operationId: decision.operationId, toolCallId: call.id }, }; return [ From ed6b16ab784910160ba923df306d0ab94992238f Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 08:47:38 +0800 Subject: [PATCH 06/30] refactor(runtime): remove transient Tool Result authority Generated-by: Codex --- .../src/__tests__/ai-sdk-backend.test.ts | 80 ++++++++++- .../pre-dispatch-refusal-ledger.test.ts | 11 -- .../runtime-event-read-model.test.ts | 5 - .../tool-runtime-sandbox-boundary.test.ts | 16 ++- .../__tests__/tool-runtime-settlement.test.ts | 131 ++++++++---------- .../tool-runtime-sqlite-boundary.test.ts | 8 +- packages/runtime/src/ai-sdk-backend.ts | 94 +++++++------ .../src/durable-tool-result-projection.ts | 8 -- packages/runtime/src/model-history.ts | 2 - packages/runtime/src/tool-runtime.ts | 53 +------ 10 files changed, 207 insertions(+), 201 deletions(-) diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 52cc3e2e03..818a3d48f8 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -341,6 +341,78 @@ describe('AiSdkBackend ApplyPatch routing', () => { await assertApplyPatchHistoryDowngraded(targetConnection, targetConnection.defaultModel!); }); + test('preserves a durable projection failure when apply_patch history is downgraded', async () => { + const model = completionModel(); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [nativeApplyPatchTool()], + newId: idGenerator(), + now: monotonicClock(), + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'continue', + context: [], + runtimeContext: [ + runtimeEvent({ + id: 'rt-call', + turnId: 'turn-previous', + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'call-1', + name: 'apply_patch', + args: [ + '*** Begin Patch', + '*** Update File: file.txt', + '@@', + '-before', + '+after', + '*** End Patch', + ].join('\n'), + }, + }), + runtimeEvent({ + id: 'rt-result', + turnId: 'turn-previous', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'call-1', + name: 'apply_patch', + result: { status: 'completed', output: 'Applied 1 file operation.' }, + modelProjection: { + version: 1, + kind: 'failure', + reason: 'projection_failed', + message: + 'The tool completed, but its model-visible result could not be projected safely.', + }, + }, + }), + ], + }), + ); + + const replayText = (compactPrompt(model) as Array<{ content: any[] }>) + .flatMap((message) => message.content) + .filter((part) => part.type === 'text') + .map((part) => part.text) + .join('\n'); + assert.match(replayText, /could not be projected safely/); + assert.doesNotMatch(replayText, /ApplyPatch completed/); + }); + test('preserves a multi-file ApplyPatch fact when structured replay cannot represent it', async () => { const model = completionModel(); const backend = createTestAiSdkBackend({ @@ -3710,6 +3782,7 @@ describe('AiSdkBackend model history', () => { 'base64', ); let calls = 0; + let artifactReads = 0; const anchor = runtimeTextEvent({ id: 'runtime-user', turnId: 'turn-1', @@ -3785,7 +3858,11 @@ describe('AiSdkBackend model history', () => { }, ], supportsVision: true, - readAttachmentBytes: async () => ({ ok: true, bytes: pngBytes }), + maxProviderImageRequestBytes: pngBytes.byteLength, + readAttachmentBytes: async () => { + artifactReads += 1; + return { ok: true, bytes: pngBytes }; + }, loadTurnRuntimeEvents: async () => ledger, newId: idGenerator(), now: monotonicClock(), @@ -3806,6 +3883,7 @@ describe('AiSdkBackend model history', () => { assert.ok( result.value.some((part: any) => part.type === 'file' && part.mediaType === 'image/png'), ); + assert.equal(artifactReads, 1); }); test('reloads durable multi-tool settlement before terminal continuation', async () => { diff --git a/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts b/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts index 19292e2d96..884d8647ea 100644 --- a/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts +++ b/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts @@ -36,8 +36,6 @@ import { } from '../session-event-runtime-mapper.js'; import type { RuntimeEventMapContext } from '../session-event-runtime-mapper.js'; import type { RuntimeCommitSink } from '../runtime-commit-sink.js'; -import { buildRuntimeEventModelReplayPlan } from '../model-history.js'; -import { durableProjectionToToolResultOutput } from '../durable-tool-result-projection.js'; import { LOOP_GATE_IDENTICAL_THRESHOLD, type MakaTool, type ToolRuntime } from '../tool-runtime.js'; import { createTestToolRuntime } from './execution-boundary-test-helpers.js'; @@ -411,15 +409,6 @@ test('arguments the schema rejects leave a matched call/response pair on the gen assert.equal(operation?.callEvent?.content?.kind, 'function_call'); assert.equal(operation?.responseEvent?.content?.kind, 'function_response'); assert.equal(operation?.dispatchEvent, undefined); - const replayResult = buildRuntimeEventModelReplayPlan(ledger).items.find( - (item) => item.kind === 'tool_result', - ); - assert.deepEqual( - settlement.modelOutput, - replayResult?.modelProjection - ? durableProjectionToToolResultOutput(replayResult.modelProjection) - : undefined, - ); }); test('a dispatched call still claims the T1 lane and settles through the commit sink', async () => { diff --git a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts index b987324dbf..e49f9e3a1a 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -694,11 +694,6 @@ describe('projectRuntimeEventsToStoredMessages', () => { { version: 1, kind: 'text', text: 'stable model fact' }, { version: 1, kind: 'text', text: 'stable model fact' }, ]); - expect(results.map((result) => result?.modelProjectionSource)).toEqual([ - 'durable', - 'durable', - 'durable', - ]); }); test('folds retired permission modes while projecting persisted tool results', () => { diff --git a/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts index df190f08c7..e38ca4ebd9 100644 --- a/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts @@ -1006,6 +1006,7 @@ describe('ToolRuntime session sandbox boundary', () => { getPermissionPauseTarget: () => null, }); + const events: SessionEvent[] = []; const settlement = await runtime.settleToolCall({ tool: buildRequestSandboxBoundaryTool() as unknown as MakaTool, turnId: 'turn-1', @@ -1016,14 +1017,19 @@ describe('ToolRuntime session sandbox boundary', () => { }, abortSignal: new AbortController().signal, eventSink: { - push: () => {}, - pushAndWaitUntilConsumed: async () => {}, + push: (event) => events.push(event), + pushAndWaitUntilConsumed: async (event) => { + events.push(event); + }, }, }); - assert.deepEqual(settlement.modelOutput, { - type: 'error-text', - value: `Error: ${SANDBOX_BOUNDARY_UNAVAILABLE}`, + assert.equal(settlement.providerError, SANDBOX_BOUNDARY_UNAVAILABLE); + assert.deepEqual(events.find((event) => event.type === 'tool_result')?.modelProjection, { + version: 1, + kind: 'text', + text: `Error: ${SANDBOX_BOUNDARY_UNAVAILABLE}`, + isError: true, }); }); }); diff --git a/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts index c8b28024ff..8130a76531 100644 --- a/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts @@ -25,6 +25,7 @@ import { createGenesisExecutionBoundary, } from '@maka/core/sandbox-boundary'; import { type LlmConnection } from '@maka/core/llm-connections'; +import type { SessionEvent } from '@maka/core/events'; import { type SessionHeader } from '@maka/core/session'; import { buildForegroundBashTool, buildManagedBashTool } from '../shell-tools.js'; import { ToolRuntime, type MakaTool, type ToolRuntimeInput } from '../tool-runtime.js'; @@ -78,8 +79,9 @@ describe('ToolRuntime settlement', () => { assert.deepEqual(allowed.result, { ok: true }); }); - it('keeps the durable Bash command while omitting it from the live model output', async () => { + it('keeps the durable Bash command while omitting it from the durable projection', async () => { const runtime = makeRuntime(); + const events: SessionEvent[] = []; const bash = buildForegroundBashTool({ description: 'shell', execute: async () => ({ @@ -98,14 +100,17 @@ describe('ToolRuntime settlement', () => { input: { command: 'printf durable-command' }, abortSignal: new AbortController().signal, eventSink: { - push: () => {}, - pushAndWaitUntilConsumed: async () => {}, + push: (event) => events.push(event), + pushAndWaitUntilConsumed: async (event) => { + events.push(event); + }, }, }); assert.equal((settlement.result as { cmd?: unknown }).cmd, 'printf durable-command'); - assert.deepEqual(settlement.modelOutput, { - type: 'json', + assert.deepEqual(settledProjection(events), { + version: 1, + kind: 'json', value: { kind: 'terminal', cwd: '/workspace/repo', @@ -194,6 +199,7 @@ describe('ToolRuntime settlement', () => { for (const [index, terminal] of terminalResults.entries()) { const runtime = makeRuntime(); + const events: SessionEvent[] = []; const bash = buildManagedBashTool({ runForegroundBash: async () => terminal, runBackgroundBash: async () => { @@ -208,20 +214,27 @@ describe('ToolRuntime settlement', () => { input: { command: terminal.cmd, boundary_intent: 'current' }, abortSignal: new AbortController().signal, eventSink: { - push: () => {}, - pushAndWaitUntilConsumed: async () => {}, + push: (event) => events.push(event), + pushAndWaitUntilConsumed: async (event) => { + events.push(event); + }, }, }); const { cmd: _cmd, ...projected } = terminal; assert.deepEqual(settlement.result, terminal); - assert.deepEqual(settlement.modelOutput, { type: 'json', value: projected }); + assert.deepEqual(settledProjection(events), { + version: 1, + kind: 'json', + value: projected, + }); assert.equal(terminalResults[index]?.cmd, terminal.cmd); } }); it('preserves live provider error mapping', async () => { const runtime = makeRuntime(); + const events: SessionEvent[] = []; const result = { error: 'internal detail', text: 'tool text', @@ -242,19 +255,27 @@ describe('ToolRuntime settlement', () => { input: {}, abortSignal: new AbortController().signal, eventSink: { - push: () => {}, - pushAndWaitUntilConsumed: async () => {}, + push: (event) => events.push(event), + pushAndWaitUntilConsumed: async (event) => { + events.push(event); + }, }, }); assert.deepEqual(settlement, { result, - modelOutput: { type: 'error-text', value: 'Error: safe model detail' }, + providerError: 'safe model detail', + }); + assert.deepEqual(settledProjection(events), { + version: 1, + kind: 'text', + text: 'Error: safe model detail', + isError: true, }); }); it('records apply_patch failures without changing their provider output shape', async () => { - const events: Array<{ type: string; isError?: boolean }> = []; + const events: SessionEvent[] = []; const runtime = makeRuntime(); const settlement = await runtime.settleToolCall({ tool: { @@ -279,8 +300,10 @@ describe('ToolRuntime settlement', () => { events.some((event) => event.type === 'tool_result' && event.isError === true), true, ); - assert.deepEqual(settlement.modelOutput, { - type: 'json', + assert.deepEqual(settlement.providerError, 'dispatch failed'); + assert.deepEqual(settledProjection(events), { + version: 1, + kind: 'json', value: { status: 'failed', output: 'dispatch failed' }, }); }); @@ -288,8 +311,8 @@ describe('ToolRuntime settlement', () => { it('falls back from provider text to the raw error message', async () => { const runtime = makeRuntime(); for (const [result, expected] of [ - [{ error: 'internal detail', text: 'tool text' }, 'Error: tool text'], - [{ error: 'internal detail' }, 'Error: internal detail'], + [{ error: 'internal detail', text: 'tool text' }, 'tool text'], + [{ error: 'internal detail' }, 'internal detail'], ] as const) { const settlement = await runtime.settleToolCall({ tool: tool(() => result), @@ -304,13 +327,13 @@ describe('ToolRuntime settlement', () => { }, }); - assert.deepEqual(settlement.modelOutput, { type: 'error-text', value: expected }); + assert.equal(settlement.providerError, expected); } }); it('keeps structured durable failures on the live success arm', async () => { const runtime = makeRuntime(); - const events: Array<{ type: string; isError?: boolean }> = []; + const events: SessionEvent[] = []; const result = { kind: 'subagent', agentName: 'Reviewer', @@ -340,53 +363,18 @@ describe('ToolRuntime settlement', () => { events.some((event) => event.type === 'tool_result' && event.isError === true), true, ); - assert.deepEqual(settlement.modelOutput, { type: 'json', value: result }); - }); - - it('materializes a durable artifact projection for the current continuation', async () => { - const result = { - kind: 'image', - mimeType: 'image/png', - ref: { kind: 'session_context' as const, sessionId: 'session-1', refId: 'artifact-1' }, - }; - const runtime = makeRuntime({ - materializeDurableToolResultProjection: async ({ toolCallId, projection }) => { - assert.equal(toolCallId, 'call-1'); - assert.deepEqual(projection, { - version: 1, - kind: 'content', - parts: [ - { - kind: 'artifact', - mediaType: 'image/png', - ref: result.ref, - }, - ], - }); - return { type: 'text', value: 'materialized image' }; - }, + assert.deepEqual(settledProjection(events), { + version: 1, + kind: 'json', + value: result, }); - - const settlement = await runtime.settleToolCall({ - tool: tool(() => result), - turnId: 'turn-1', - stepId: 'step-1', - toolCallId: 'call-1', - input: {}, - abortSignal: new AbortController().signal, - eventSink: { - push: () => {}, - pushAndWaitUntilConsumed: async () => {}, - }, - }); - - assert.deepEqual(settlement.modelOutput, { type: 'text', value: 'materialized image' }); }); it('rejects direct-only nested tools before permission argument projection or implementation', async () => { let permissionProjectionCalls = 0; let implementationCalls = 0; const runtime = makeRuntime(); + const events: SessionEvent[] = []; const settlement = await runtime.settleToolCall({ tool: { @@ -405,8 +393,10 @@ describe('ToolRuntime settlement', () => { input: {}, abortSignal: new AbortController().signal, eventSink: { - push: () => {}, - pushAndWaitUntilConsumed: async () => {}, + push: (event) => events.push(event), + pushAndWaitUntilConsumed: async (event) => { + events.push(event); + }, }, origin: 'code_mode', parentToolCallId: 'exec-1', @@ -414,9 +404,12 @@ describe('ToolRuntime settlement', () => { assert.equal(permissionProjectionCalls, 0); assert.equal(implementationCalls, 0); - assert.deepEqual(settlement.modelOutput, { - type: 'error-text', - value: 'Error: Tool Read is direct-only and cannot run inside exec.', + assert.equal(settlement.providerError, 'Tool Read is direct-only and cannot run inside exec.'); + assert.deepEqual(settledProjection(events), { + version: 1, + kind: 'text', + text: 'Error: Tool Read is direct-only and cannot run inside exec.', + isError: true, }); }); @@ -541,13 +534,7 @@ describe('ToolRuntime settlement', () => { function makeRuntime( overrides: Partial< - Pick< - ToolRuntimeInput, - | 'materializeDurableToolResultProjection' - | 'readExecutionBoundary' - | 'spawnChildSession' - | 'runId' - > + Pick > = {}, ): ToolRuntime { return createTestToolRuntime({ @@ -563,6 +550,10 @@ function makeRuntime( }); } +function settledProjection(events: readonly SessionEvent[]) { + return events.find((event) => event.type === 'tool_result')?.modelProjection; +} + function tool(impl: MakaTool['impl']): MakaTool { return { name: 'Read', diff --git a/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts index a8b3f97a65..2d8f8fd3a4 100644 --- a/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts @@ -357,7 +357,7 @@ describe('ToolRuntime with real SQLite boundary', () => { impl: async () => ({ ok: true }), }; - const settlement = await runtime.settleToolCall({ + await runtime.settleToolCall({ tool: exclusive, turnId: 'turn-1', stepId: 'step-1', @@ -438,7 +438,7 @@ describe('ToolRuntime with real SQLite boundary', () => { const published: SessionEvent[] = []; - const settlement = await runtime.settleToolCall({ + await runtime.settleToolCall({ tool, turnId: 'turn-1', toolCallId: 'provider-call-1', @@ -481,10 +481,6 @@ describe('ToolRuntime with real SQLite boundary', () => { kind: 'json', value: { ok: true, text: 'contents' }, }); - assert.deepEqual(settlement.modelOutput, { - type: 'json', - value: { ok: true, text: 'contents' }, - }); const nextTurnProjection = buildRuntimeEventModelReplayPlan(events).items.find( (item) => item.kind === 'tool_result', )?.modelProjection; diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 1040c989c7..1c4d1e8f87 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -863,6 +863,37 @@ function nativeApplyPatchFailureOutput(output: ToolResultOutput): ToolResultOutp }; } +function durableApplyPatchReplayFactText( + input: unknown, + projection: DurableToolResultProjection, + isError: boolean, +): string | null { + if (projection.kind === 'json') { + const fact = applyPatchReplayFactText(input, projection, isError); + if (fact) return fact; + } + const output = durableProjectionToToolResultOutput(projection); + switch (output.type) { + case 'text': + case 'error-text': + return output.value; + case 'json': + case 'error-json': + return JSON.stringify(output.value); + case 'content': { + const text = output.value + .filter((part): part is Extract => part.type === 'text') + .map((part) => part.text) + .join('\n'); + return text || null; + } + case 'execution-denied': + return output.reason + ? `ApplyPatch execution denied: ${output.reason}` + : 'ApplyPatch execution denied.'; + } +} + /** * One Code Mode cell runs at a time on a backend, with one allowed to wait. * Widening either needs evidence that concurrent cells are wanted; none exists @@ -1110,7 +1141,7 @@ export class AiSdkBackend implements AgentBackend { createProviderRequestTracker: (trackerInput) => this.createProviderRequestTracker(trackerInput), materializeRuntimeReplayPlan: (plan, imageBudget, checkpoint) => - this.materializeRuntimeReplayPlan(plan, imageBudget, undefined, checkpoint), + this.materializeRuntimeReplayPlan(plan, imageBudget, checkpoint), canReplayProviderNative: (plan) => this.canReplayProviderNative(plan), }); if ( @@ -1327,12 +1358,6 @@ export class AiSdkBackend implements AgentBackend { ...(identity.runId ? { runId: identity.runId } : {}), orchestrationMode: identity.orchestrationMode, ...(identity.invocationId ? { invocationId: identity.invocationId } : {}), - materializeDurableToolResultProjection: ({ toolCallId, projection }) => - this.materializeDurableToolResultProjection( - identity.scope().imageBudget, - projection, - toolCallId, - ), persistDurableProjectionArtifact: input.persistDurableProjectionArtifact, spawnChildSession: input.spawnChildSession, listChildAgents: input.listChildAgents, @@ -1863,7 +1888,6 @@ export class AiSdkBackend implements AgentBackend { content: currentUserContent, } as ModelMessage, ]; - const settledModelOutputs = new Map(); const loadDurableTurnEvents = async (): Promise => { const loadTurnRuntimeEvents = this.input.loadTurnRuntimeEvents; if (!loadTurnRuntimeEvents) { @@ -1915,7 +1939,6 @@ export class AiSdkBackend implements AgentBackend { const currentTurnMessages = await this.materializeRuntimeReplayPlan( replayPlan, scope.imageBudget, - settledModelOutputs, projectionCheckpoint, ); return projectionCheckpoint @@ -2684,13 +2707,6 @@ export class AiSdkBackend implements AgentBackend { } } await queue.waitUntilConsumedThroughCurrent(); - for (let index = 0; index < returnedToolCalls.length; index += 1) { - const toolCall = returnedToolCalls[index]; - const settlement = settlements[index]; - if (toolCall && settlement) { - settledModelOutputs.set(toolCall.toolCallId, settlement.modelOutput); - } - } const continuationWillRun = (maxSteps === undefined || runtimeSteps < maxSteps) && @@ -3113,7 +3129,7 @@ export class AiSdkBackend implements AgentBackend { const tool = snapshot.get(name); if (!tool) throw new Error(`Tool "${name}" is not active or nestable in this cell`); const parsedInput = await validateCodeModeToolInput(tool, input); - const settlement = await scope.toolRuntime.settleToolCallRaw({ + const settlement = await scope.toolRuntime.settleToolCall({ tool, turnId: context.turnId, toolCallId: `${context.toolCallId}:nested:${this.newId()}`, @@ -3624,7 +3640,6 @@ export class AiSdkBackend implements AgentBackend { messages: await this.materializeRuntimeReplayPlan( plan, scope.imageBudget, - undefined, projectedHistoryCompactCheckpoint, ), gate: 'runtime_replay_text_only', @@ -3648,7 +3663,6 @@ export class AiSdkBackend implements AgentBackend { ? await this.materializeRuntimeReplayPlan( degradedPlan, scope.imageBudget, - undefined, projectedHistoryCompactCheckpoint, ) : await materializeReplayFallback(), @@ -3667,7 +3681,6 @@ export class AiSdkBackend implements AgentBackend { messages: await this.materializeRuntimeReplayPlan( plan, scope.imageBudget, - undefined, projectedHistoryCompactCheckpoint, ), gate: 'runtime_replay_provider_native', @@ -3739,7 +3752,6 @@ export class AiSdkBackend implements AgentBackend { private async materializeRuntimeReplayPlan( plan: RuntimeEventModelReplayPlan, budget: ProviderImageBudget, - settledModelOutputs?: ReadonlyMap, historyCompactCheckpoint?: HistoryCompactCheckpoint, ): Promise { type ToolCallItem = Extract; @@ -3863,22 +3875,18 @@ export class AiSdkBackend implements AgentBackend { result: ToolResultItem, toolName: string, ): Promise => { - const settledOutput = settledModelOutputs?.get(result.toolCallId); - const output = - result.modelProjection && - (result.modelProjectionSource === 'durable' || settledOutput === undefined) - ? await this.materializeDurableToolResultProjection( - budget, - result.modelProjection, - `runtime-event:${result.eventId}:tool-result`, - ) - : (settledOutput ?? - (await this.materializeToolResultOutput( - budget, - result.output, - result.isError, - `runtime-event:${result.eventId}:tool-result`, - ))); + const output = result.modelProjection + ? await this.materializeDurableToolResultProjection( + budget, + result.modelProjection, + `runtime-event:${result.eventId}:tool-result`, + ) + : await this.materializeToolResultOutput( + budget, + result.output, + result.isError, + `runtime-event:${result.eventId}:tool-result`, + ); if (toolName !== 'apply_patch') return output; return result.isError ? nativeApplyPatchFailureOutput(output) : output; }; @@ -4070,11 +4078,13 @@ export class AiSdkBackend implements AgentBackend { break; } downgradedApplyPatchCalls.delete(item.toolCallId); - const replayFact = applyPatchReplayFactText( - downgradedCall.input, - item.output, - item.isError, - ); + const replayFact = item.modelProjection + ? durableApplyPatchReplayFactText( + downgradedCall.input, + item.modelProjection, + item.isError, + ) + : applyPatchReplayFactText(downgradedCall.input, item.output, item.isError); if (!replayFact) break; if (downgradedCall.stepId) { const stepFacts = replayFactsByStep.get(downgradedCall.stepId) ?? []; diff --git a/packages/runtime/src/durable-tool-result-projection.ts b/packages/runtime/src/durable-tool-result-projection.ts index c3d527da9d..e3bab98232 100644 --- a/packages/runtime/src/durable-tool-result-projection.ts +++ b/packages/runtime/src/durable-tool-result-projection.ts @@ -125,10 +125,6 @@ export function encodeDefaultDurableToolResultOutput( ); } -export function durableProjectionHasArtifacts(projection: DurableToolResultProjection): boolean { - return projection.kind === 'content' && projection.parts.some((part) => part.kind === 'artifact'); -} - export function durableProjectionToToolResultOutput( projection: DurableToolResultProjection, ): ToolResultOutput { @@ -168,7 +164,6 @@ export function durableProjectionToToolResultOutput( export type EffectiveToolResultProjection = | { kind: 'projection'; - source: 'durable' | 'compatibility'; projection: DurableToolResultProjection; legacyOutput: unknown; } @@ -188,14 +183,12 @@ export function decodeEffectiveToolResultProjection( try { return { kind: 'projection', - source: 'durable', projection: decodeDurableToolResultProjection(content.modelProjection), legacyOutput: content.result, }; } catch { return { kind: 'projection', - source: 'durable', projection: DURABLE_TOOL_RESULT_PROJECTION_FAILURE, legacyOutput: content.result, }; @@ -234,7 +227,6 @@ export function decodeEffectiveToolResultProjection( } return { kind: 'projection', - source: 'compatibility', projection, legacyOutput: output, }; diff --git a/packages/runtime/src/model-history.ts b/packages/runtime/src/model-history.ts index 568d375b05..1a74cde6c6 100644 --- a/packages/runtime/src/model-history.ts +++ b/packages/runtime/src/model-history.ts @@ -174,7 +174,6 @@ export type RuntimeEventModelReplayItem = output: unknown; isError: boolean; modelProjection?: DurableToolResultProjection; - modelProjectionSource?: 'durable' | 'compatibility'; providerExecuted?: boolean; eventId: string; ts: number; @@ -657,7 +656,6 @@ export function buildRuntimeEventModelReplayPlan( ...(effective.kind === 'projection' ? { modelProjection: effective.projection, - modelProjectionSource: effective.source, } : {}), isError: event.content.isError === true, diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index 8a844cdebc..069e2f0f3f 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -84,8 +84,6 @@ import type { RunTraceLike } from './run-trace.js'; import { AwaitRegistry } from './await-registry.js'; import type { ToolResultOutput } from './model-protocol.js'; import { - durableProjectionToToolResultOutput, - durableProjectionHasArtifacts, compatibilityToolResultProjection, encodeDefaultDurableToolResultOutput, encodeDurableToolResultOutput, @@ -149,14 +147,8 @@ export interface DurableSessionEventSink { } export interface ToolSettlement { - result: unknown; - modelOutput: ToolResultOutput; -} - -export interface RawToolSettlement { result: unknown; providerError?: string; - modelProjection: DurableToolResultProjection; } export interface MakaTool

{ @@ -362,10 +354,6 @@ export interface ToolRuntimeInput { runId?: string; orchestrationMode?: OrchestrationMode; invocationId?: string; - materializeDurableToolResultProjection?: (options: { - toolCallId: string; - projection: DurableToolResultProjection; - }) => ToolResultOutput | PromiseLike; persistDurableProjectionArtifact?: (input: { turnId: string; toolCallId: string; @@ -732,25 +720,6 @@ export class ToolRuntime { * provider-facing error output; durable runtime commit failures still reject. */ async settleToolCall(call: ResolvedMakaToolCall): Promise { - const settlement = await this.settleToolCallRaw(call); - const modelOutput = - durableProjectionHasArtifacts(settlement.modelProjection) && - this.input.materializeDurableToolResultProjection - ? await this.input.materializeDurableToolResultProjection({ - toolCallId: call.toolCallId, - projection: settlement.modelProjection, - }) - : durableProjectionToToolResultOutput(settlement.modelProjection); - return { result: settlement.result, modelOutput }; - } - - /** - * Settle a tool without producing provider-visible output. Runtime-owned - * nested calls use this path because their result is consumed by Code Mode, - * not sent as a provider tool-result part; materializing it could spend - * turn-scoped provider resources such as the image budget. - */ - async settleToolCallRaw(call: ResolvedMakaToolCall): Promise { const settlement = this.performToolSettlement(call); // Tracked so endTurn can wait out unwinds already in flight (#2253): // their T2 outcomes must land before the stop path settles the run's @@ -763,8 +732,7 @@ export class ToolRuntime { return settlement; } - private async performToolSettlement(call: ResolvedMakaToolCall): Promise { - let modelProjection: DurableToolResultProjection | undefined; + private async performToolSettlement(call: ResolvedMakaToolCall): Promise { const result = await this.executeTool( call.tool, call.turnId, @@ -780,22 +748,9 @@ export class ToolRuntime { ...(call.providerOptions !== undefined ? { providerOptions: call.providerOptions } : {}), }, call.stepId, - (projection) => { - modelProjection = projection; - }, ); const providerError = providerToolErrorMessage(result); - if (modelProjection === undefined) { - const projected = this.projectToolResult( - call.tool, - call.turnId, - call.toolCallId, - call.input, - result, - ); - modelProjection = isPromiseLike(projected) ? await projected : projected; - } - return { result, modelProjection, ...(providerError ? { providerError } : {}) }; + return { result, ...(providerError ? { providerError } : {}) }; } private projectToolResult( @@ -1051,7 +1006,6 @@ export class ToolRuntime { maxResultBytes?: number; }, stepId?: string, - captureProjection?: (projection: DurableToolResultProjection) => void, ): Promise { const rawExecutionArgs = snapshotToolArgs(args); const sandboxBoundaryDecisionGeneration = this.sandboxBoundaryDecisionGeneration; @@ -1758,7 +1712,6 @@ export class ToolRuntime { const { result, outcome } = settledExecution.value; output.flush(); const { content, durationMs, modelProjection } = outcome; - captureProjection?.(modelProjection); // Keep the full provider-facing terminal classification. `isError` is // sufficient for the durable response envelope, but it intentionally // collapses `aborted` into an error bit and therefore cannot drive live @@ -1952,7 +1905,6 @@ export class ToolRuntime { terminalResult, ); const modelProjection = isPromiseLike(projected) ? await projected : projected; - captureProjection?.(modelProjection); const durableOutcome = await durableAttempt?.commitOutcome( terminalFailure.content, true, @@ -2033,7 +1985,6 @@ export class ToolRuntime { activityIdentity, durableAttempt, ); - captureProjection?.(modelProjection); this.input.recordToolInvocation?.({ sessionId: this.input.sessionId, turnId, From 06307a2c06b28ec9901d9933136f04bdc3ee611c Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 08:47:43 +0800 Subject: [PATCH 07/30] fix(storage): reuse projected image artifacts Generated-by: Codex --- .../__tests__/artifact-attachments.test.ts | 19 +++++++++++++++++++ packages/storage/src/artifact-attachments.ts | 13 +++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/storage/src/__tests__/artifact-attachments.test.ts b/packages/storage/src/__tests__/artifact-attachments.test.ts index a84c3366e9..082783e0dc 100644 --- a/packages/storage/src/__tests__/artifact-attachments.test.ts +++ b/packages/storage/src/__tests__/artifact-attachments.test.ts @@ -220,6 +220,25 @@ describe('artifact attachment authority', () => { assert.deepEqual(await store.list('session-1'), []); }); }); + + test('snapshotter reuses one content-addressed artifact for the same turn image', async () => { + await withStore(async (store) => { + const snapshot = createReadImageSnapshotter(store); + const input = { + sessionId: 'session-1', + turnId: 'turn-1', + name: 'Tool Result image', + bytes: Uint8Array.from([1, 2, 3]), + mimeType: 'image/png', + }; + + const first = await snapshot(input); + const repeated = await snapshot(input); + + assert.deepEqual(repeated, first); + assert.equal((await store.list('session-1')).length, 1); + }); + }); }); function sessionFileRef(relativePath: string, sessionId = 'session-1'): StorageRef { diff --git a/packages/storage/src/artifact-attachments.ts b/packages/storage/src/artifact-attachments.ts index c274c0c628..7008296463 100644 --- a/packages/storage/src/artifact-attachments.ts +++ b/packages/storage/src/artifact-attachments.ts @@ -23,6 +23,7 @@ import { READ_IMAGE_TOO_LARGE_MESSAGE, type AttachmentByteReader, } from '@maka/core/attachments'; +import { createHash } from 'node:crypto'; import { type StorageRef, type ToolResultContent } from '@maka/core/events'; import type { ReadImageSnapshotReader } from '@maka/core/context-offload'; import type { @@ -130,7 +131,19 @@ export function createReadImageSnapshotter(artifactStore: Pick MAX_READ_IMAGE_BYTES) { throw new Error(READ_IMAGE_TOO_LARGE_MESSAGE); } + const id = `image_${createHash('sha256') + .update(input.sessionId, 'utf8') + .update('\0', 'utf8') + .update(input.turnId, 'utf8') + .update('\0', 'utf8') + .update(input.name, 'utf8') + .update('\0', 'utf8') + .update(input.mimeType, 'utf8') + .update('\0', 'utf8') + .update(input.bytes) + .digest('hex')}`; const artifact = await artifactStore.create({ + id, sessionId: input.sessionId, turnId: input.turnId, name: input.name, From 320f23a12811a0f6104c9a0996259bae7cae6be8 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 08:51:51 +0800 Subject: [PATCH 08/30] fix(runtime): remap projected artifacts on copy Generated-by: Codex --- .../src/__tests__/conversation-copy.test.ts | 113 ++++++++++++++++++ packages/runtime/src/conversation-copy.ts | 20 ++++ .../src/durable-tool-result-projection.ts | 13 ++ 3 files changed, 146 insertions(+) diff --git a/packages/runtime/src/__tests__/conversation-copy.test.ts b/packages/runtime/src/__tests__/conversation-copy.test.ts index cf7ccbb3a4..92087aa383 100644 --- a/packages/runtime/src/__tests__/conversation-copy.test.ts +++ b/packages/runtime/src/__tests__/conversation-copy.test.ts @@ -1357,6 +1357,119 @@ test('conversation copy rewrites a complete tool recovery bundle atomically', as } }); +test('conversation copy remaps durable projection artifacts with the copied result', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-conversation-projection-copy-')); + const runStore = createSqliteAgentRunStore(root); + const runtimeEventStore = createSqliteRuntimeStore(join(root, 'runtime.sqlite')); + try { + await runStore.ready?.(); + await runStore.createRun( + agentRunHeader({ + runId: 'run-source', + invocationId: 'invocation-source', + turnId: 'turn-1', + cwd: root, + }), + ); + const sourceRef = { + kind: 'session_file' as const, + sessionId: 'session-source', + relativePath: 'artifact-source', + }; + const sourceEvents: RuntimeEvent[] = [ + runtimeEvent({ + id: 'event-user', + role: 'user', + author: 'user', + content: { kind: 'text', text: 'read the image' }, + }), + runtimeEvent({ + id: 'event-call', + ts: 2, + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'provider-call-1', + name: 'Read', + args: { path: 'image.png' }, + }, + }), + runtimeEvent({ + id: 'event-result', + ts: 3, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'provider-call-1', + name: 'Read', + result: { kind: 'image', mimeType: 'image/png', ref: sourceRef }, + modelProjection: { + version: 1, + kind: 'content', + parts: [{ kind: 'artifact', mediaType: 'image/png', ref: sourceRef }], + }, + }, + }), + runtimeEvent({ id: 'event-terminal', ts: 4, status: 'completed' }), + ]; + await runtimeEventStore.importConversationCopyRuntimeEvents('session-source', [ + { runId: 'run-source', events: sourceEvents }, + ]); + await runStore.appendEvent('session-source', 'run-source', { + type: 'run_completed', + id: 'completed-source', + runId: 'run-source', + sessionId: 'session-source', + turnId: 'turn-1', + ts: 4, + }); + const source = await new RuntimeReadModel({ runStore, runtimeEventStore }).getSessionView( + 'session-source', + ); + + await cloneConversationRuntimeLedger({ + plan: await prepareTestCopyPlan(source, source.messages, runStore, runtimeEventStore), + copiedMessages: source.messages, + referenceMap: { + mode: 'exact', + linkedChildren: { mode: 'reject' }, + sourceSessionId: 'session-source', + targetSessionId: 'session-target', + artifactIds: new Map([['artifact-source', 'artifact-target']]), + relativePaths: new Map(), + }, + runStore, + runtimeEventStore, + newId: () => crypto.randomUUID(), + }); + + const [targetRun] = await runStore.listSessionRuns('session-target'); + assert.ok(targetRun); + const targetResult = ( + await runtimeEventStore.readRuntimeEvents('session-target', targetRun.runId) + ).find((event) => event.content?.kind === 'function_response'); + assert.equal(targetResult?.content?.kind, 'function_response'); + if (targetResult?.content?.kind !== 'function_response') return; + const expectedRef = { + kind: 'session_file', + sessionId: 'session-target', + relativePath: 'artifact-target', + }; + assert.deepEqual( + targetResult.content.modelProjection?.kind === 'content' + ? targetResult.content.modelProjection.parts[0] + : undefined, + { kind: 'artifact', mediaType: 'image/png', ref: expectedRef }, + ); + } finally { + runtimeEventStore.close(); + runStore.close?.(); + await rm(root, { recursive: true, force: true }); + } +}); + test('conversation copy rewrites the parent operation id of a nested Code Mode call', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-conversation-copy-parent-op-')); const runStore = createSqliteAgentRunStore(root); diff --git a/packages/runtime/src/conversation-copy.ts b/packages/runtime/src/conversation-copy.ts index 6a4054f81c..41d3c72618 100644 --- a/packages/runtime/src/conversation-copy.ts +++ b/packages/runtime/src/conversation-copy.ts @@ -58,6 +58,7 @@ import { isArchivedToolResultPlaceholder, type ArchivedToolResultPlaceholder, } from './tool-result-archive.js'; +import { rewriteDurableToolResultProjectionArtifactRefs } from './durable-tool-result-projection.js'; export interface ConversationCopySlice { readonly messages: readonly StoredMessage[]; @@ -1161,6 +1162,14 @@ function rewriteRuntimeEventReferences( ? { ...event.content, result: rewriteRuntimeToolResult(event.content.result, references), + ...(event.content.modelProjection + ? { + modelProjection: rewriteDurableToolResultProjectionArtifactRefs( + event.content.modelProjection, + (ref) => rewriteProjectionArtifactRef(ref, references), + ), + } + : {}), } : event.content; const refs = event.refs @@ -1588,6 +1597,17 @@ function rewriteStorageRef( }; } +function rewriteProjectionArtifactRef( + ref: Extract, + references: ConversationCopyArtifactReferenceMap, +): Extract { + const rewritten = rewriteStorageRef(ref, references); + if (rewritten.kind !== 'session_context' && rewritten.kind !== 'session_file') { + throw new Error('Conversation copy produced an invalid projection Artifact reference'); + } + return rewritten; +} + function rewriteArchivedToolResult( value: ArchivedToolResultPlaceholder, references: ConversationCopyMessageReferenceMap, diff --git a/packages/runtime/src/durable-tool-result-projection.ts b/packages/runtime/src/durable-tool-result-projection.ts index e3bab98232..400da4757a 100644 --- a/packages/runtime/src/durable-tool-result-projection.ts +++ b/packages/runtime/src/durable-tool-result-projection.ts @@ -161,6 +161,19 @@ export function durableProjectionToToolResultOutput( } } +export function rewriteDurableToolResultProjectionArtifactRefs( + projection: DurableToolResultProjection, + rewrite: (ref: DurableProjectionArtifactRef) => DurableProjectionArtifactRef, +): DurableToolResultProjection { + if (projection.kind !== 'content') return projection; + return { + ...projection, + parts: projection.parts.map((part) => + part.kind === 'artifact' ? { ...part, ref: rewrite(part.ref) } : part, + ), + }; +} + export type EffectiveToolResultProjection = | { kind: 'projection'; From 4542178d64024cc2195fd99c66538830012019ad Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 09:05:58 +0800 Subject: [PATCH 09/30] refactor(runtime): remove synthetic projection return Generated-by: Codex --- packages/runtime/src/tool-runtime.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index 069e2f0f3f..482feeb25e 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -935,7 +935,7 @@ export class ToolRuntime { parentOperationId?: string; } = {}, attempt?: DurableToolAttempt, - ): Promise { + ): Promise { const content: ToolResultContent = { kind: 'text', text: formatSyntheticToolErrorText(text), @@ -988,7 +988,6 @@ export class ToolRuntime { modelProjection, ...activityIdentity, } satisfies ToolResultEvent); - return modelProjection; } private async executeTool( @@ -1973,7 +1972,7 @@ export class ToolRuntime { : uncertainOutcome ? `outcome_unknown: ${formatSyntheticToolErrorText(err)}` : formatSyntheticToolErrorText(err); - const modelProjection = await this.writeSyntheticToolResult( + await this.writeSyntheticToolResult( toolUseId, turnId, tool.name, From 2fc3d9051f27951ab3498960137899674f49b001 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 09:08:29 +0800 Subject: [PATCH 10/30] refactor(runtime): privatize synthetic result writer Generated-by: Codex --- .../src/__tests__/ai-sdk-backend.test.ts | 26 +++++++++---------- packages/runtime/src/tool-runtime.ts | 2 +- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 818a3d48f8..fd8068dfac 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -6985,7 +6985,7 @@ describe('AiSdkBackend error surfaces', () => { assert.equal(text.endsWith('…'), true); }); - test('writeSyntheticToolResult never persists raw secret-shaped errors', async () => { + test('tool settlement never persists raw secret-shaped synthetic errors', async () => { const messages: ToolResultMessage[] = []; const events: SessionEvent[] = []; const backend = createTestAiSdkBackend({ @@ -7003,20 +7003,18 @@ describe('AiSdkBackend error surfaces', () => { now: () => 1, }); - await turnScope(backend, 'turn-1').toolRuntime.writeSyntheticToolResult( - 'tool-1', - 'turn-1', - 'Bash', - 'failed with api_key=sk-live-secret-token-value', - { - push: (event) => { - events.push(event); - }, - pushAndWaitUntilConsumed: async (event) => { - events.push(event); - }, + const tool: MakaTool = { + name: 'FailingTool', + description: 'fails with a provider secret', + parameters: {}, + impl: async () => { + throw new Error('failed with api_key=sk-live-secret-token-value'); }, - ); + }; + + await runtimeExecute(backend, tool, 'turn-1', { + push: (event) => events.push(event), + })({}, { toolCallId: 'tool-1', abortSignal: new AbortController().signal }); assert.equal(JSON.stringify(messages).includes('sk-live-secret-token-value'), false); assert.equal(JSON.stringify(events).includes('sk-live-secret-token-value'), false); diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index 482feeb25e..4981afc2a2 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -919,7 +919,7 @@ export class ToolRuntime { this.lastFailedToolCallBoundaryDetails = boundaryDetails; } - async writeSyntheticToolResult( + private async writeSyntheticToolResult( toolUseId: string, turnId: string, toolName: string, From 9043ee4df42f70fa7ba70592f3b14d6758b50a52 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 09:16:18 +0800 Subject: [PATCH 11/30] fix(runtime): validate projected images before persistence Generated-by: Codex --- .../durable-tool-result-projection.test.ts | 34 +++++++++++++++++++ .../src/durable-tool-result-projection.ts | 17 ++++++---- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts b/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts index 25e9948fd2..e610a8c3b6 100644 --- a/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts +++ b/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts @@ -24,6 +24,7 @@ import { decodeEffectiveToolResultProjection, encodeDefaultDurableToolResultOutput, encodeDurableToolResultOutput, + encodeDurableToolResultOutputWithArtifacts, } from '../durable-tool-result-projection.js'; describe('durable Tool Result projection codec', () => { @@ -120,6 +121,39 @@ describe('durable Tool Result projection codec', () => { assert.equal(projection.kind, 'failure'); }); + it('validates every inline image before persisting any projection artifact', async () => { + let writes = 0; + const projection = await encodeDurableToolResultOutputWithArtifacts( + { + type: 'content', + value: [ + { + type: 'file', + data: { type: 'data', data: Buffer.from('valid').toString('base64') }, + mediaType: 'image/png', + }, + { + type: 'file', + data: { type: 'data', data: 'not-canonical-base64' }, + mediaType: 'image/png', + }, + ], + }, + 'session-1', + async () => { + writes += 1; + return { + kind: 'session_file', + sessionId: 'session-1', + relativePath: 'artifact-1', + }; + }, + ); + + assert.equal(projection.kind, 'failure'); + assert.equal(writes, 0); + }); + it('validates default image refs through the same closed schema', () => { const legacyImage = { kind: 'image', diff --git a/packages/runtime/src/durable-tool-result-projection.ts b/packages/runtime/src/durable-tool-result-projection.ts index 400da4757a..576a66f0ad 100644 --- a/packages/runtime/src/durable-tool-result-projection.ts +++ b/packages/runtime/src/durable-tool-result-projection.ts @@ -80,17 +80,20 @@ export function encodeDurableToolResultOutputWithArtifacts( } return (async () => { try { + const decodedImages = output.value.map((part) => + part.type === 'file' && + part.data.type === 'data' && + part.mediaType.toLowerCase().startsWith('image/') + ? decodeBoundedImageData(part.data.data) + : undefined, + ); const value: Extract['value'] = []; - for (const part of output.value) { - if ( - part.type !== 'file' || - part.data.type !== 'data' || - !part.mediaType.toLowerCase().startsWith('image/') - ) { + for (const [index, part] of output.value.entries()) { + const bytes = decodedImages[index]; + if (!bytes || part.type !== 'file') { value.push(part); continue; } - const bytes = decodeBoundedImageData(part.data.data); const ref = await persistArtifact({ bytes, mediaType: part.mediaType }); value.push({ ...part, data: { ref } } as never); } From 41320c0035c1074d8b179ebb093be899b2722d4e Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 09:16:18 +0800 Subject: [PATCH 12/30] fix(runtime): reject unowned projection copies Generated-by: Codex --- .../src/__tests__/session-manager.test.ts | 89 +++++++++++++++++++ packages/runtime/src/session-manager.ts | 23 ++++- 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 880a44922e..630776deba 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -8681,6 +8681,95 @@ describe('SessionManager permission mode updates', () => { }); }); + test('conversation copy without artifact transfer fails before cloning projected refs', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const manager = makeManagerForReadCutover(store, runStore); + const session = await manager.createSession(makeInput({ name: 'Parent' })); + const runId = 'source-run'; + const turnId = 'source'; + const artifactRef = { + kind: 'session_file' as const, + sessionId: session.id, + relativePath: 'artifact-source', + }; + await seedRuntimeRun( + runStore, + makeRunHeader({ + sessionId: session.id, + runId, + turnId, + status: 'completed', + createdAt: 100, + updatedAt: 104, + completedAt: 104, + }), + [ + runtimeEvent({ + id: 'source-user', + sessionId: session.id, + runId, + turnId, + ts: 101, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'read the image' }, + }), + runtimeEvent({ + id: 'source-call', + sessionId: session.id, + runId, + turnId, + ts: 102, + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'read-1', + name: 'Read', + args: { path: 'image.png' }, + }, + }), + runtimeEvent({ + id: 'source-result', + sessionId: session.id, + runId, + turnId, + ts: 103, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'read-1', + name: 'Read', + result: { kind: 'image', mimeType: 'image/png', ref: artifactRef }, + modelProjection: { + version: 1, + kind: 'content', + parts: [{ kind: 'artifact', mediaType: 'image/png', ref: artifactRef }], + }, + }, + }), + runtimeEvent({ + id: 'source-terminal', + sessionId: session.id, + runId, + turnId, + ts: 104, + status: 'completed', + actions: { endInvocation: true }, + }), + ], + ); + + await assert.rejects( + manager.branchFromTurn(session.id, { sourceTurnId: turnId, name: 'Child' }), + (error: Error & { code?: string }) => + error.code === 'conversation_copy_projection_artifact_transfer_required', + ); + assert.equal((await store.list()).length, 1); + }); + test('conversation copies materialize requestless hosted permission history', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 96591f9d73..e8bba38de7 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -259,6 +259,15 @@ function runtimeCommitSinkFromEventStore( : undefined; } +function runtimeEventHasProjectionArtifact(event: RuntimeEvent): boolean { + const content = event.content; + return ( + content?.kind === 'function_response' && + content.modelProjection?.kind === 'content' && + content.modelProjection.parts.some((part) => part.kind === 'artifact') + ); +} + export interface StopSessionInput { source?: 'stop_button' | 'graph_supervisor'; mode?: BackendStopMode; @@ -4477,13 +4486,25 @@ export class SessionManager { copiedMessages: readonly StoredMessage[], ): Promise { if (!this.deps.runStore || !this.deps.runtimeEventStore) return undefined; - return prepareConversationRuntimeLedgerCopy({ + const plan = await prepareConversationRuntimeLedgerCopy({ sourceSessionId, sourceEvents: sourceView.events, copiedMessages, runStore: this.deps.runStore, runtimeEventStore: this.deps.runtimeEventStore, }); + const events = [ + ...plan.inlineRuntimeEvents, + ...plan.runs.flatMap(({ runtimeEvents }) => runtimeEvents), + ]; + if (events.some(runtimeEventHasProjectionArtifact)) { + const error = new Error( + 'Conversation copy requires artifact ownership transfer for durable Tool Result projections', + ) as Error & { code: string }; + error.code = 'conversation_copy_projection_artifact_transfer_required'; + throw error; + } + return plan; } private async cloneConversationRuntimeLedger( From 01a3dc7d58cd6e1440ee7fda44752125436b12a1 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 10:00:11 +0800 Subject: [PATCH 13/30] refactor(runtime): remove retired conversation copy owner Generated-by: Codex --- packages/core/src/session-name.ts | 1 - .../src/__tests__/session-manager.test.ts | 1083 +---------------- packages/runtime/src/session-manager.ts | 227 ---- 3 files changed, 1 insertion(+), 1310 deletions(-) diff --git a/packages/core/src/session-name.ts b/packages/core/src/session-name.ts index 7b670b6e66..993e426d16 100644 --- a/packages/core/src/session-name.ts +++ b/packages/core/src/session-name.ts @@ -31,7 +31,6 @@ * module so every write path can call it: * - `sessions:create` IPC → runtime.create → store.create * - `sessions:rename` IPC → runtime.renameSession → store.rename - * - `sessions:branchFromTurn` IPC → runtime.branchFromTurn → store.create * * Pipeline (applied in order): * 1. **Runtime type guard**: `typeof input !== 'string'` → typed diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 630776deba..e1bab6db37 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -28,7 +28,6 @@ import { } from '@maka/core/sandbox-boundary'; import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; import { DEEP_RESEARCH_SESSION_LABEL } from '@maka/core/deep-research'; -import { SIDE_CONVERSATION_SESSION_LABEL } from '@maka/core/side-conversation'; import { RUNTIME_CONTINUATION_AUTHORITY_V1 } from '@maka/core/runtime-event-store'; import { deriveTurnRecords } from '@maka/core/session'; import { isSessionInlineRun } from '@maka/core/agent-run'; @@ -48,7 +47,7 @@ import type { UserMessageInput, } from '@maka/core/runtime-inputs'; import type { ExecutionBoundary } from '@maka/core/sandbox-boundary'; -import type { SessionEvent, ShellRunSnapshotResult } from '@maka/core/events'; +import type { SessionEvent } from '@maka/core/events'; import type { AgentGraphIntentClaim, AgentGraphIntentClaimStore, @@ -8632,366 +8631,6 @@ describe('SessionManager permission mode updates', () => { expect(regenUser?.type === 'user' ? regenUser.text : undefined).toBe('aborted turn text'); }); - test('branchFromTurn copies through the RuntimeEvent-primary message boundary', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const manager = makeManagerForReadCutover(store, runStore); - const session = await manager.createSession(makeInput({ name: 'Parent' })); - await seedRuntimeReadTurn({ - store, - runStore, - sessionId: session.id, - turnId: 'source', - runId: 'source-run', - userText: 'runtime branch context', - assistantText: 'runtime branch answer', - legacyIdPrefix: 'legacy', - }); - store.failNextReadMessagesFor.set(session.id, 1); - - const child = await manager.branchFromTurn(session.id, { - sourceTurnId: 'source', - name: 'Child', - }); - - const childMessages = await store.readMessages(child.id); - expect(childMessages[0]).toMatchObject({ - type: 'user', - turnId: 'source', - text: 'runtime branch context', - }); - expect(childMessages[1]).toMatchObject({ - type: 'assistant', - turnId: 'source', - text: 'runtime branch answer', - }); - expect(childMessages[2]).toMatchObject({ type: 'system_note', kind: 'session_start' }); - expect(childMessages.some((message) => message.type === 'turn_state')).toBe(false); - - const runtimeMessages = await manager.getMessages(child.id); - expect(runtimeMessages[0]).toMatchObject({ - type: 'user', - turnId: 'source', - text: 'runtime branch context', - }); - expect(runtimeMessages[1]).toMatchObject({ - type: 'assistant', - turnId: 'source', - text: 'runtime branch answer', - }); - }); - - test('conversation copy without artifact transfer fails before cloning projected refs', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const manager = makeManagerForReadCutover(store, runStore); - const session = await manager.createSession(makeInput({ name: 'Parent' })); - const runId = 'source-run'; - const turnId = 'source'; - const artifactRef = { - kind: 'session_file' as const, - sessionId: session.id, - relativePath: 'artifact-source', - }; - await seedRuntimeRun( - runStore, - makeRunHeader({ - sessionId: session.id, - runId, - turnId, - status: 'completed', - createdAt: 100, - updatedAt: 104, - completedAt: 104, - }), - [ - runtimeEvent({ - id: 'source-user', - sessionId: session.id, - runId, - turnId, - ts: 101, - role: 'user', - author: 'user', - content: { kind: 'text', text: 'read the image' }, - }), - runtimeEvent({ - id: 'source-call', - sessionId: session.id, - runId, - turnId, - ts: 102, - role: 'model', - author: 'agent', - content: { - kind: 'function_call', - id: 'read-1', - name: 'Read', - args: { path: 'image.png' }, - }, - }), - runtimeEvent({ - id: 'source-result', - sessionId: session.id, - runId, - turnId, - ts: 103, - role: 'tool', - author: 'tool', - content: { - kind: 'function_response', - id: 'read-1', - name: 'Read', - result: { kind: 'image', mimeType: 'image/png', ref: artifactRef }, - modelProjection: { - version: 1, - kind: 'content', - parts: [{ kind: 'artifact', mediaType: 'image/png', ref: artifactRef }], - }, - }, - }), - runtimeEvent({ - id: 'source-terminal', - sessionId: session.id, - runId, - turnId, - ts: 104, - status: 'completed', - actions: { endInvocation: true }, - }), - ], - ); - - await assert.rejects( - manager.branchFromTurn(session.id, { sourceTurnId: turnId, name: 'Child' }), - (error: Error & { code?: string }) => - error.code === 'conversation_copy_projection_artifact_transfer_required', - ); - assert.equal((await store.list()).length, 1); - }); - - test('conversation copies materialize requestless hosted permission history', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - const backendInstances: TestBackend[] = []; - backends.register('ai-sdk', (ctx) => { - const backend = new TestBackend(ctx); - backendInstances.push(backend); - return backend; - }); - const session = await store.create(makeInput({ name: 'Parent' })); - const header = makeRunHeader({ - sessionId: session.id, - runId: 'source-run', - turnId: 'source', - status: 'completed', - createdAt: 100, - updatedAt: 130, - completedAt: 130, - }); - await seedCanonicalPermissionRun(runStore, header, false); - const sourcePermissionEvents = await runStore.readRuntimeEvents(session.id, header.runId); - expect(sourcePermissionEvents.some((event) => event.actions?.permissionRequest)).toBe(false); - expect(sourcePermissionEvents.some((event) => event.content?.kind === 'function_call')).toBe( - false, - ); - await seedRuntimeRun( - runStore, - makeRunHeader({ - sessionId: session.id, - runId: 'later-run', - turnId: 'later', - status: 'completed', - createdAt: 140, - updatedAt: 141, - completedAt: 141, - }), - [ - runtimeEvent({ - id: 'later-user', - sessionId: session.id, - runId: 'later-run', - turnId: 'later', - ts: 140, - role: 'user', - author: 'user', - content: { kind: 'text', text: 'replace this turn' }, - }), - runtimeEvent({ - id: 'later-terminal', - sessionId: session.id, - runId: 'later-run', - turnId: 'later', - ts: 141, - status: 'completed', - actions: { endInvocation: true }, - }), - ], - ); - let sourceInteractionAvailable = true; - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId: nextId(), - now: nextNow(6_755), - interactionAuthority: testInteractionAuthority(), - canonicalPermissionOutcomes: { - readPermissionOutcome: async () => - sourceInteractionAvailable ? canonicalPermissionRecord(header) : undefined, - }, - }); - - const child = await manager.branchFromTurn(session.id, { - sourceTurnId: header.turnId, - name: 'Child', - }); - const revision = await manager.reviseBeforeTurn(session.id, { sourceTurnId: 'later' }); - sourceInteractionAvailable = false; - - const [childSourceRun] = await runStore.listSessionRuns(child.id); - const childSourceEvents = childSourceRun - ? await runStore.readRuntimeEvents(child.id, childSourceRun.runId) - : []; - expect( - childSourceEvents.find((event) => event.actions?.permissionDecision)?.actions - ?.permissionDecision, - ).toMatchObject({ - requestId: 'request-canonical', - toolName: 'Write', - decision: 'deny', - }); - expect(childSourceEvents.some((event) => event.actions?.permissionRequest)).toBe(false); - expect(childSourceEvents.some((event) => event.actions?.permissionAnswerAccepted)).toBe(false); - - for (const copiedSession of [child, revision]) { - expect( - (await manager.getMessages(copiedSession.id)).find( - (message) => message.type === 'permission_decision', - ), - ).toMatchObject({ - type: 'permission_decision', - id: 'request-canonical', - turnId: header.turnId, - toolUseId: 'tool-canonical', - toolName: 'Write', - decision: 'deny', - reviewer: 'user', - }); - } - - await drain(manager.sendMessage(child.id, { turnId: 'child-next', text: 'continue' })); - expect( - backendInstances - .find((backend) => backend.sessionId === child.id) - ?.sendInputs[0]?.context.find((message) => message.type === 'permission_decision'), - ).toMatchObject({ - id: 'request-canonical', - toolUseId: 'tool-canonical', - toolName: 'Write', - decision: 'deny', - }); - }); - - test('branch child next turn receives cloned RuntimeEvent context', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - const backendInstances: TestBackend[] = []; - backends.register('ai-sdk', (ctx) => { - const backend = new TestBackend(ctx); - backendInstances.push(backend); - return backend; - }); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId: nextId(), - now: nextNow(6_870), - }); - const session = await manager.createSession(makeInput({ name: 'Parent' })); - - await drain(manager.sendMessage(session.id, { turnId: 'source', text: 'branch seed' })); - const child = await manager.branchFromTurn(session.id, { - sourceTurnId: 'source', - name: 'Child', - }); - await store.appendMessage(child.id, { - type: 'assistant', - id: 'child-cache-only', - turnId: 'cache-only', - ts: 6_999, - text: 'cache-only child context', - modelId: 'fake-model', - }); - - await drain(manager.sendMessage(child.id, { turnId: 'child-next', text: 'child follow-up' })); - - const childInput = backendInstances[1]?.sendInputs[0]; - if (!childInput) throw new Error('child backend input was not recorded'); - expect( - childInput.context.some( - (message) => - message.type === 'user' && message.turnId === 'source' && message.text === 'branch seed', - ), - ).toBe(true); - expect( - childInput.context.some( - (message) => message.type === 'assistant' && message.id === 'child-cache-only', - ), - ).toBe(false); - expect(childInput.runtimeContext?.map((event) => event.turnId)).toEqual([ - 'source', - 'source', - 'source', - ]); - expect(childInput.runtimeContext?.[0]?.sessionId).toBe(child.id); - }); - - test('branchFromTurn removes an incomplete target when Runtime ledger copy fails', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore({ failRuntimeEventAppendAfter: 5 }); - const manager = makeManagerForReadCutover(store, runStore); - const session = await manager.createSession(makeInput({ name: 'Parent' })); - await seedRuntimeReadTurn({ - store, - runStore, - sessionId: session.id, - turnId: 'source', - runId: 'source-run', - userText: 'runtime branch context', - assistantText: 'runtime branch answer', - legacyIdPrefix: 'legacy', - }); - - let branchError: unknown; - try { - await manager.branchFromTurn(session.id, { sourceTurnId: 'source', name: 'Child' }); - } catch (error) { - branchError = error; - } - expect(branchError instanceof Error ? branchError.message : String(branchError)).toContain( - 'runtime event append failed', - ); - - const child = (await store.list()).find((summary) => summary.parentSessionId === session.id); - expect(child).toBeUndefined(); - const childRuns = await runStore.listSessionRuns('session-2'); - for (const run of childRuns) { - const runtimeEvents = await runStore.readRuntimeEvents('session-2', run.runId); - const hasTerminalFact = runtimeEvents.some(isTerminalRuntimeEvent); - expect( - run.status === 'completed' || run.status === 'failed' || run.status === 'cancelled' - ? hasTerminalFact - : true, - ).toBe(true); - } - }); - test('multi-run RuntimeEvent projection preserves retry regenerate and branch lineage on turns', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); @@ -11827,726 +11466,6 @@ describe('SessionManager permission mode updates', () => { expect(turn?.errorClass).toBe('app_restarted'); }); - test('branchFromTurn marks side conversations without changing ordinary branches', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - backends.register( - 'ai-sdk', - (ctx) => new EventBackend(ctx, [{ type: 'complete', stopReason: 'end_turn' }]), - ); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId: nextId(), - now: nextNow(15_500), - }); - const session = await manager.createSession(makeInput({ name: 'Parent', labels: ['kept'] })); - await drain(manager.sendMessage(session.id, { turnId: 'source', text: 'context' })); - - const ordinary = await manager.branchFromTurn(session.id, { - sourceTurnId: 'source', - name: 'Ordinary', - }); - const side = await manager.branchFromTurn(session.id, { - sourceTurnId: 'source', - name: 'Side', - sideConversation: true, - }); - - expect(ordinary.labels).toEqual(['kept']); - expect(side.labels).toEqual(['kept', SIDE_CONVERSATION_SESSION_LABEL]); - }); - - test('conversation copies inherit the authoritative execution boundary', async () => { - const store = new AtomicBoundaryMemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - backends.register( - 'ai-sdk', - (ctx) => new EventBackend(ctx, [{ type: 'complete', stopReason: 'end_turn' }]), - ); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId: nextId(), - now: nextNow(15_050), - }); - const boundaries: ExecutionBoundary[] = [ - { - kind: 'managed', - revision: 4, - profile: applySandboxBoundaryExpansion(createWorkspaceWritePermissionProfile(), { - filesystem: { - entries: [{ path: '/approved/release.txt', access: 'write', scope: 'exact' }], - }, - }), - }, - { kind: 'external', revision: 3 }, - ]; - - for (const [index, boundary] of boundaries.entries()) { - const source = await manager.createSession(makeInput({ name: `Source ${index}` })); - await drain(manager.sendMessage(source.id, { turnId: 'first', text: 'keep' })); - await drain(manager.sendMessage(source.id, { turnId: 'second', text: 'replace' })); - store.forceBoundary(source.id, boundary); - - const branch = await manager.branchFromTurn(source.id, { sourceTurnId: 'first' }); - const revision = await manager.reviseBeforeTurn(source.id, { sourceTurnId: 'second' }); - - for (const copy of [branch, revision]) { - expect(await store.readExecutionBoundary(copy.id)).toEqual({ - ...boundary, - revision: 0, - }); - } - } - }); - - test('branch and revision sessions rewrite tool operation authority facts', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId: nextId(), - now: nextNow(15_100), - }); - const session = await manager.createSession(makeInput({ name: 'Authority source' })); - const header = await store.readHeader(session.id); - await seedRuntimeRun( - runStore, - makeRunHeader({ - sessionId: session.id, - runId: 'authority-source-run', - turnId: 'authority-source-turn', - status: 'completed', - cwd: header.cwd, - createdAt: 1, - updatedAt: 3, - completedAt: 3, - }), - [ - runtimeEvent({ - id: 'authority-source-user', - sessionId: session.id, - invocationId: 'authority-source-invocation', - runId: 'authority-source-run', - turnId: 'authority-source-turn', - ts: 1, - role: 'user', - author: 'user', - content: { kind: 'text', text: 'authority-bearing history' }, - }), - runtimeEvent({ - id: 'authority-source-dispatch', - sessionId: session.id, - invocationId: 'authority-source-invocation', - runId: 'authority-source-run', - turnId: 'authority-source-turn', - ts: 2, - role: 'system', - author: 'system', - actions: { - toolDispatch: { - protocol: 't1_after_preflight_v1', - operationId: 'authority-operation', - providerToolCallId: 'authority-call', - toolName: 'Write', - canonicalArgsHash: `sha256:${'a'.repeat(64)}`, - recoveryMode: 'reconcile', - }, - }, - refs: { toolCallId: 'authority-call', operationId: 'authority-operation' }, - }), - runtimeEvent({ - id: 'authority-source-terminal', - sessionId: session.id, - invocationId: 'authority-source-invocation', - runId: 'authority-source-run', - turnId: 'authority-source-turn', - ts: 3, - status: 'completed', - actions: { endInvocation: true }, - }), - ], - ); - await seedRuntimeRun( - runStore, - makeRunHeader({ - sessionId: session.id, - runId: 'authority-later-run', - turnId: 'authority-later-turn', - status: 'completed', - cwd: header.cwd, - createdAt: 4, - updatedAt: 5, - completedAt: 5, - }), - [ - runtimeEvent({ - id: 'authority-later-user', - sessionId: session.id, - invocationId: 'authority-later-invocation', - runId: 'authority-later-run', - turnId: 'authority-later-turn', - ts: 4, - role: 'user', - author: 'user', - content: { kind: 'text', text: 'later boundary' }, - }), - runtimeEvent({ - id: 'authority-later-terminal', - sessionId: session.id, - invocationId: 'authority-later-invocation', - runId: 'authority-later-run', - turnId: 'authority-later-turn', - ts: 5, - status: 'completed', - actions: { endInvocation: true }, - }), - ], - ); - - const copies = [ - await manager.branchFromTurn(session.id, { - sourceTurnId: 'authority-source-turn', - name: 'Tool branch', - }), - await manager.branchBeforeTurn(session.id, { - sourceTurnId: 'authority-later-turn', - name: 'Tool branch before later turn', - }), - await manager.reviseBeforeTurn(session.id, { - sourceTurnId: 'authority-later-turn', - }), - ]; - for (const copy of copies) { - const [targetRun] = await runStore.listSessionRuns(copy.id); - assert.ok(targetRun); - const targetEvents = await runStore.readRuntimeEvents(copy.id, targetRun.runId); - const dispatch = targetEvents.find((event) => event.actions?.toolDispatch); - const targetOperationId = dispatch?.actions?.toolDispatch?.operationId; - assert.ok(targetOperationId); - assert.notEqual(targetOperationId, 'authority-operation'); - assert.equal(dispatch?.refs?.operationId, targetOperationId); - assert.equal(targetRun.sessionId, copy.id); - } - }); - - test('fails before creating branch or revision sessions for child continuation authority', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends: new BackendRegistry(), - newId: nextId(), - now: nextNow(15_150), - }); - const session = await manager.createSession(makeInput({ name: 'Legacy continuation' })); - const header = await store.readHeader(session.id); - await seedRuntimeRun( - runStore, - makeRunHeader({ - sessionId: session.id, - runId: 'continuation-parent-run', - turnId: 'continuation-parent-turn', - status: 'completed', - cwd: header.cwd, - createdAt: 1, - updatedAt: 2, - completedAt: 2, - }), - [ - runtimeEvent({ - id: 'continuation-parent-user', - sessionId: session.id, - invocationId: 'continuation-parent-invocation', - runId: 'continuation-parent-run', - turnId: 'continuation-parent-turn', - ts: 1, - role: 'user', - author: 'user', - content: { kind: 'text', text: 'retain parent and child closure' }, - }), - runtimeEvent({ - id: 'continuation-parent-terminal', - sessionId: session.id, - invocationId: 'continuation-parent-invocation', - runId: 'continuation-parent-run', - turnId: 'continuation-parent-turn', - ts: 2, - status: 'completed', - actions: { endInvocation: true }, - }), - ], - ); - await seedRuntimeRun( - runStore, - makeRunHeader({ - sessionId: session.id, - runId: 'legacy-continuation-run', - turnId: 'legacy-child-turn', - parentRunId: 'continuation-parent-run', - agentId: 'child-agent', - status: 'completed', - cwd: header.cwd, - continuationSource: { - sourceInvocationId: 'legacy-source-invocation', - sourceRunId: 'legacy-source-run', - sourceTurnId: 'legacy-source-turn', - sourceRuntimeEventHighWater: 1, - }, - createdAt: 3, - updatedAt: 4, - completedAt: 4, - }), - [ - runtimeEvent({ - id: 'legacy-continuation-user', - sessionId: session.id, - invocationId: 'legacy-continuation-run', - runId: 'legacy-continuation-run', - turnId: 'legacy-child-turn', - ts: 3, - role: 'user', - author: 'user', - content: { kind: 'text', text: 'legacy continuation history' }, - }), - runtimeEvent({ - id: 'legacy-continuation-terminal', - sessionId: session.id, - invocationId: 'legacy-continuation-run', - runId: 'legacy-continuation-run', - turnId: 'legacy-child-turn', - ts: 4, - status: 'completed', - actions: { endInvocation: true }, - }), - ], - ); - await seedRuntimeRun( - runStore, - makeRunHeader({ - sessionId: session.id, - runId: 'continuation-later-run', - turnId: 'continuation-later-turn', - status: 'completed', - cwd: header.cwd, - createdAt: 5, - updatedAt: 6, - completedAt: 6, - }), - [ - runtimeEvent({ - id: 'continuation-later-user', - sessionId: session.id, - invocationId: 'continuation-later-invocation', - runId: 'continuation-later-run', - turnId: 'continuation-later-turn', - ts: 5, - role: 'user', - author: 'user', - content: { kind: 'text', text: 'later boundary' }, - }), - runtimeEvent({ - id: 'continuation-later-terminal', - sessionId: session.id, - invocationId: 'continuation-later-invocation', - runId: 'continuation-later-run', - turnId: 'continuation-later-turn', - ts: 6, - status: 'completed', - actions: { endInvocation: true }, - }), - ], - ); - - const attempts = [ - () => - manager.branchFromTurn(session.id, { - sourceTurnId: 'continuation-parent-turn', - name: 'Blocked child continuation branch', - }), - () => - manager.branchBeforeTurn(session.id, { - sourceTurnId: 'continuation-later-turn', - name: 'Blocked child continuation branch before', - }), - () => - manager.reviseBeforeTurn(session.id, { - sourceTurnId: 'continuation-later-turn', - }), - ]; - for (const attempt of attempts) { - await expectRejects(attempt(), /typed identity rewriting/i); - } - expect((await store.list()).map((candidate) => candidate.id)).toEqual([session.id]); - }); - - test('hydrates an inherited running ShellRun with its source-session owner', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - const ref = 'maka://runtime/background-tasks/pty-parent'; - const sourceSnapshot: ShellRunSnapshotResult = { - kind: 'shell_run', - ref, - mode: 'pty', - status: 'running', - cwd: '/tmp/workspace', - cmd: 'interactive', - startedAt: 1, - updatedAt: 2, - revision: 2, - output: { - mode: 'pty', - screen: 'ready', - scrollback: '', - cols: 80, - rows: 24, - cursor: { x: 5, y: 0, visible: true }, - alternateScreen: false, - truncated: false, - redacted: false, - }, - }; - let ownerAvailable = true; - const shellRuns = { - async listSessionUpdates() { - return []; - }, - async getSessionUpdate() { - return undefined; - }, - async inspectResource(sessionId: string, candidateRef: string) { - if (ownerAvailable && candidateRef === ref && sessionId === 'session-1') - return sourceSnapshot; - const error = new Error('missing') as NodeJS.ErrnoException; - error.code = 'ENOENT'; - throw error; - }, - } as unknown as ShellRunProcessManager; - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - shellRuns, - newId: nextId(), - now: nextNow(15_250), - }); - const parent = await manager.createSession(makeInput({ name: 'Parent' })); - await seedRuntimeRun( - runStore, - makeRunHeader({ - sessionId: parent.id, - runId: 'source-run', - turnId: 'source', - status: 'completed', - createdAt: 1, - updatedAt: 4, - completedAt: 4, - }), - [ - runtimeEvent({ - id: 'user-1', - sessionId: parent.id, - runId: 'source-run', - turnId: 'source', - ts: 1, - role: 'user', - author: 'user', - content: { kind: 'text', text: 'start' }, - }), - runtimeEvent({ - id: 'call-1', - sessionId: parent.id, - runId: 'source-run', - turnId: 'source', - ts: 2, - role: 'model', - author: 'agent', - content: { - kind: 'function_call', - id: 'bash-1', - name: 'Bash', - args: { command: 'interactive', run_in_background: true, pty: true }, - }, - refs: { toolCallId: 'bash-1' }, - }), - runtimeEvent({ - id: 'result-1', - sessionId: parent.id, - runId: 'source-run', - turnId: 'source', - ts: 3, - role: 'tool', - author: 'tool', - content: { - kind: 'function_response', - id: 'bash-1', - name: 'Bash', - result: { - kind: 'shell_run', - ref, - mode: 'pty', - status: 'running', - cwd: '/tmp/workspace', - cmd: 'interactive', - startedAt: 1, - updatedAt: 1, - revision: 1, - }, - isError: false, - }, - refs: { toolCallId: 'bash-1' }, - }), - runtimeEvent({ - id: 'complete-1', - sessionId: parent.id, - runId: 'source-run', - turnId: 'source', - ts: 4, - role: 'system', - author: 'system', - status: 'completed', - actions: { endInvocation: true }, - }), - ], - ); - await seedRuntimeRun( - runStore, - makeRunHeader({ - sessionId: parent.id, - runId: 'later-run', - turnId: 'later', - status: 'completed', - createdAt: 5, - updatedAt: 6, - completedAt: 6, - }), - [ - runtimeEvent({ - id: 'later-user', - sessionId: parent.id, - runId: 'later-run', - turnId: 'later', - ts: 5, - role: 'user', - author: 'user', - content: { kind: 'text', text: 'later' }, - }), - runtimeEvent({ - id: 'later-complete', - sessionId: parent.id, - runId: 'later-run', - turnId: 'later', - ts: 6, - role: 'system', - author: 'system', - status: 'completed', - actions: { endInvocation: true }, - }), - ], - ); - const child = await manager.branchFromTurn(parent.id, { - sourceTurnId: 'source', - name: 'Child', - }); - const revision = await manager.reviseBeforeTurn(parent.id, { sourceTurnId: 'later' }); - - const updates = await manager.listShellRunUpdates(child.id); - - expect(updates).toHaveLength(1); - expect(updates[0]?.sessionId).toBe(child.id); - expect(updates[0]?.ownership).toEqual({ - kind: 'source_owned', - sourceSessionId: parent.id, - ownerSessionId: parent.id, - }); - expect(updates[0]?.sourceToolCallId).toBe('bash-1'); - expect(updates[0]?.result).toEqual(sourceSnapshot); - expect(await manager.getShellRunUpdate(child.id, sourceSnapshot.ref)).toEqual(updates[0]); - expect( - await manager.getShellRunUpdate(child.id, 'maka://runtime/background-tasks/missing-shell'), - ).toBeNull(); - - const revisionUpdates = await manager.listShellRunUpdates(revision.id); - expect(revisionUpdates).toHaveLength(1); - expect(revisionUpdates[0]?.ownership).toEqual({ - kind: 'source_owned', - sourceSessionId: parent.id, - ownerSessionId: parent.id, - }); - expect(revisionUpdates[0]?.result).toEqual(sourceSnapshot); - - ownerAvailable = false; - await store.remove(parent.id); - const danglingUpdates = await manager.listShellRunUpdates(child.id); - expect(danglingUpdates).toHaveLength(1); - expect(danglingUpdates[0]?.sessionId).toBe(child.id); - expect(danglingUpdates[0]?.ownership).toEqual({ - kind: 'source_unavailable', - sourceSessionId: parent.id, - }); - expect(danglingUpdates[0]?.result.status).toBe('running'); - expect(danglingUpdates[0]?.result.output).toBe(undefined); - expect(await manager.getShellRunUpdate(child.id, sourceSnapshot.ref)).toEqual( - danglingUpdates[0], - ); - }); - - test('reviseBeforeTurn creates an in-conversation version without ordinary branch lineage', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - backends.register( - 'ai-sdk', - (ctx) => new EventBackend(ctx, [{ type: 'complete', stopReason: 'end_turn' }]), - ); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId: nextId(), - now: nextNow(16_700), - }); - const session = await manager.createSession( - makeInput({ - name: 'Conversation', - projectId: 'project-1', - collaborationMode: 'plan', - orchestrationMode: 'swarm', - }), - ); - await manager.setFlagged(session.id, true); - await drain(manager.sendMessage(session.id, { turnId: 'first', text: 'keep me' })); - await drain(manager.sendMessage(session.id, { turnId: 'second', text: 'replace me' })); - - const version2 = await manager.reviseBeforeTurn(session.id, { sourceTurnId: 'second' }); - - expect(version2.name).toBe('Conversation'); - expect(version2.projectId).toBe('project-1'); - expect(version2.isFlagged).toBe(true); - expect(version2.collaborationMode).toBe('plan'); - expect(version2.orchestrationMode).toBe('swarm'); - expect(version2.parentSessionId).toBeUndefined(); - expect(version2.branchOfTurnId).toBeUndefined(); - expect(version2.revisionRootSessionId).toBe(session.id); - expect(version2.revisionParentSessionId).toBe(session.id); - expect(version2.revisionOfTurnId).toBe('second'); - expect(version2.revisionIndex).toBe(2); - expect(version2.revisionState).toBe('preparing'); - expect((await store.readHeader(version2.id)).collaborationMode).toBe('plan'); - expect((await store.readHeader(version2.id)).orchestrationMode).toBe('swarm'); - await drain( - manager.sendMessage( - version2.id, - { turnId: 'edited-second', text: 'replacement' }, - { - onRunStarted: async () => { - await manager.commitRevisionVersion(version2.id); - }, - }, - ), - ); - expect((await store.readHeader(version2.id)).revisionState).toBe('committed'); - const messages = await store.readMessages(version2.id); - expect(messages.some((message) => (message as { turnId?: string }).turnId === 'first')).toBe( - true, - ); - expect(messages.some((message) => (message as { turnId?: string }).turnId === 'second')).toBe( - false, - ); - - const version3 = await manager.reviseBeforeTurn(version2.id, { sourceTurnId: 'first' }); - expect(version3.projectId).toBe('project-1'); - expect(version3.revisionRootSessionId).toBe(session.id); - expect(version3.revisionParentSessionId).toBe(version2.id); - expect(version3.revisionIndex).toBe(3); - expect(version3.revisionState).toBe('preparing'); - expect(version3.parentSessionId).toBeUndefined(); - }); - - test('startup recovery removes empty preparing revisions and commits admitted edits', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - backends.register( - 'ai-sdk', - (ctx) => new EventBackend(ctx, [{ type: 'complete', stopReason: 'end_turn' }]), - ); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId: nextId(), - now: nextNow(16_900), - }); - const root = await manager.createSession(makeInput({ name: 'Recovery root' })); - await drain(manager.sendMessage(root.id, { turnId: 'first', text: 'original' })); - - const empty = await manager.reviseBeforeTurn(root.id, { sourceTurnId: 'first' }); - expect(empty.revisionState).toBe('preparing'); - await manager.recoverInterruptedSessions(); - let removedError: unknown; - try { - await store.readHeader(empty.id); - } catch (error) { - removedError = error; - } - expect(removedError instanceof Error ? removedError.message : String(removedError)).toContain( - 'Unknown session', - ); - - const admitted = await manager.reviseBeforeTurn(root.id, { sourceTurnId: 'first' }); - await drain(manager.sendMessage(admitted.id, { turnId: 'edited', text: 'edited prompt' })); - expect((await store.readHeader(admitted.id)).revisionState).toBe('preparing'); - await manager.recoverInterruptedSessions(); - expect((await store.readHeader(admitted.id)).revisionState).toBe('committed'); - }); - - test('branchBeforeTurn rejects an unknown turn', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - backends.register( - 'ai-sdk', - (ctx) => new EventBackend(ctx, [{ type: 'complete', stopReason: 'end_turn' }]), - ); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId: nextId(), - now: nextNow(16_800), - }); - const session = await manager.createSession(makeInput({ name: 'Parent' })); - await drain(manager.sendMessage(session.id, { turnId: 'only', text: 'the one prompt' })); - - let branchError: unknown; - try { - await manager.branchBeforeTurn(session.id, { sourceTurnId: 'nope' }); - } catch (error) { - branchError = error; - } - expect(branchError instanceof Error ? branchError.message : String(branchError)).toContain( - 'Cannot branch before unknown turn', - ); - }); }); async function drainAll(iterable: AsyncIterable): Promise { diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index e8bba38de7..b8497a9df0 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -61,9 +61,7 @@ import type { } from '@maka/core/session'; import type { CreateSessionInput, - BranchFromTurnInput, RegenerateTurnInput, - ReviseBeforeTurnInput, UserMessageInput, SessionListFilter, } from '@maka/core/runtime-inputs'; @@ -91,7 +89,6 @@ import { } from '@maka/core/plan'; import { DEFAULT_SESSION_NAME } from '@maka/core/session-name'; import { DEEP_RESEARCH_SESSION_LABEL, isDeepResearchSession } from '@maka/core/deep-research'; -import { SIDE_CONVERSATION_SESSION_LABEL } from '@maka/core/side-conversation'; import { SUBAGENT_SESSION_RUNTIME_SCHEMA_VERSION, SUBAGENT_SESSION_SPAWN_SCHEMA_VERSION, @@ -144,12 +141,6 @@ import { type RuntimeReadModelSessionView, } from './runtime-read-model.js'; import { inspectAgentRunReadModel, type AgentRunInspectModel } from './agent-run-inspect.js'; -import { - cloneConversationRuntimeLedger as cloneConversationLedger, - createConversationCopySlice, - prepareConversationRuntimeLedgerCopy, - type ConversationRuntimeLedgerCopyPlan, -} from './conversation-copy.js'; import { firstRuntimeRepairRunId, RuntimeLedgerRepair } from './runtime-ledger-repair.js'; import { buildRecoveredTerminalRuntimeEvent, @@ -259,15 +250,6 @@ function runtimeCommitSinkFromEventStore( : undefined; } -function runtimeEventHasProjectionArtifact(event: RuntimeEvent): boolean { - const content = event.content; - return ( - content?.kind === 'function_response' && - content.modelProjection?.kind === 'content' && - content.modelProjection.parts.some((part) => part.kind === 'artifact') - ); -} - export interface StopSessionInput { source?: 'stop_button' | 'graph_supervisor'; mode?: BackendStopMode; @@ -3884,13 +3866,6 @@ export class SessionManager { }; } - async branchFromTurn(sessionId: string, input: BranchFromTurnInput): Promise { - const sourceView = await this.getSessionView(sessionId); - const slice = createConversationCopySlice(sourceView.messages, input.sourceTurnId, 'through'); - if (!slice) throw new Error(`Cannot branch from unknown turn ${input.sourceTurnId}`); - return this.createBranchSession(sessionId, sourceView, [...slice.messages], input); - } - /** Canonical, repaired source view for a Host-owned cross-Session copy. */ async readConversationCopySnapshot(sessionId: string): Promise { const readMessagesSnapshot = this.deps.store.readMessagesSnapshot; @@ -3909,148 +3884,6 @@ export class SessionManager { }; } - async branchBeforeTurn(sessionId: string, input: BranchFromTurnInput): Promise { - const sourceView = await this.getSessionView(sessionId); - const slice = createConversationCopySlice(sourceView.messages, input.sourceTurnId, 'before'); - if (!slice) throw new Error(`Cannot branch before unknown turn ${input.sourceTurnId}`); - return this.createBranchSession(sessionId, sourceView, [...slice.messages], input); - } - - /** - * Create a non-destructive edit-and-resend version. Unlike branchBeforeTurn, - * this is not a new sidebar conversation: revision lineage lets hosts fold - * every version into one conversation slot while keeping old transcripts. - */ - async reviseBeforeTurn(sessionId: string, input: ReviseBeforeTurnInput): Promise { - const sourceView = await this.getSessionView(sessionId); - const slice = createConversationCopySlice(sourceView.messages, input.sourceTurnId, 'before'); - if (!slice) throw new Error(`Cannot revise before unknown turn ${input.sourceTurnId}`); - return this.createRevisionSession(sessionId, sourceView, [...slice.messages], input); - } - - private async createRevisionSession( - sessionId: string, - sourceView: RuntimeReadModelSessionView, - copied: StoredMessage[], - input: ReviseBeforeTurnInput, - ): Promise { - const plan = await this.prepareConversationRuntimeLedgerClone(sessionId, sourceView, copied); - const [header, boundary] = await Promise.all([ - this.deps.store.readHeader(sessionId), - this.deps.store.readExecutionBoundary(sessionId), - ]); - const revisionRootSessionId = header.revisionRootSessionId ?? sessionId; - const family = (await this.deps.store.list()).filter( - (candidate) => - candidate.id === revisionRootSessionId || - candidate.revisionRootSessionId === revisionRootSessionId, - ); - const revisionIndex = - Math.max(1, ...family.map((candidate) => candidate.revisionIndex ?? 1)) + 1; - const next = await this.deps.store.create( - { - cwd: header.cwd, - ...(header.projectId !== undefined ? { projectId: header.projectId } : {}), - ...(header.llmConnectionId === undefined - ? {} - : { llmConnectionId: header.llmConnectionId }), - llmConnectionSlug: header.llmConnectionSlug, - model: header.model, - thinkingLevel: header.thinkingLevel, - permissionMode: header.permissionMode, - collaborationMode: header.collaborationMode, - orchestrationMode: header.orchestrationMode ?? 'default', - name: header.name, - labels: header.labels, - // A revision of a real branch remains in that branch's conversation - // slot; revision lineage itself must not create a branch banner. - parentSessionId: header.parentSessionId, - branchOfTurnId: header.branchOfTurnId, - revisionRootSessionId, - revisionParentSessionId: sessionId, - revisionOfTurnId: input.sourceTurnId, - revisionIndex, - revisionState: 'preparing', - status: 'active', - }, - boundary, - ); - try { - const rewritten = await this.cloneConversationRuntimeLedger(next.id, copied, plan); - if (rewritten.length > 0) await this.deps.store.appendMessages(next.id, [...rewritten]); - await this.deps.store.appendMessage(next.id, { - type: 'system_note', - id: this.deps.newId(), - ts: this.deps.now(), - kind: 'session_start', - data: { - revisionRootSessionId, - revisionParentSessionId: sessionId, - revisionOfTurnId: input.sourceTurnId, - revisionIndex, - revisionState: 'preparing', - }, - }); - await this.deps.store.updateHeader(next.id, { - isFlagged: header.isFlagged, - titleIsManual: header.titleIsManual, - }); - return headerToSummary(await this.deps.store.readHeader(next.id)); - } catch (error) { - return this.rollbackLegacyConversationCopy(next.id, error); - } - } - - private async createBranchSession( - sessionId: string, - sourceView: RuntimeReadModelSessionView, - copied: StoredMessage[], - input: BranchFromTurnInput, - ): Promise { - const plan = await this.prepareConversationRuntimeLedgerClone(sessionId, sourceView, copied); - const [header, boundary] = await Promise.all([ - this.deps.store.readHeader(sessionId), - this.deps.store.readExecutionBoundary(sessionId), - ]); - const next = await this.deps.store.create( - { - cwd: header.cwd, - ...(header.projectId !== undefined ? { projectId: header.projectId } : {}), - ...(header.llmConnectionId === undefined - ? {} - : { llmConnectionId: header.llmConnectionId }), - llmConnectionSlug: header.llmConnectionSlug, - model: header.model, - thinkingLevel: header.thinkingLevel, - permissionMode: header.permissionMode, - collaborationMode: header.collaborationMode, - orchestrationMode: header.orchestrationMode ?? 'default', - name: input.name ?? `${header.name} · 分支`, - labels: input.sideConversation - ? [...new Set([...header.labels, SIDE_CONVERSATION_SESSION_LABEL])] - : header.labels, - parentSessionId: sessionId, - branchOfTurnId: input.sourceTurnId, - status: 'active', - }, - boundary, - ); - try { - const rewritten = await this.cloneConversationRuntimeLedger(next.id, copied, plan); - if (rewritten.length > 0) await this.deps.store.appendMessages(next.id, [...rewritten]); - await this.deps.store.appendMessage(next.id, { - type: 'system_note', - id: this.deps.newId(), - ts: this.deps.now(), - kind: 'session_start', - data: { parentSessionId: sessionId, branchOfTurnId: input.sourceTurnId }, - }); - return headerToSummary(await this.deps.store.readHeader(next.id)); - } catch (error) { - return this.rollbackLegacyConversationCopy(next.id, error); - } - } - async respondToSandboxBoundary( sessionId: string, response: SandboxBoundaryResponse, @@ -4480,66 +4313,6 @@ export class SessionManager { this.preparedTranscriptLedgers.add(sessionId); } - private async prepareConversationRuntimeLedgerClone( - sourceSessionId: string, - sourceView: RuntimeReadModelSessionView, - copiedMessages: readonly StoredMessage[], - ): Promise { - if (!this.deps.runStore || !this.deps.runtimeEventStore) return undefined; - const plan = await prepareConversationRuntimeLedgerCopy({ - sourceSessionId, - sourceEvents: sourceView.events, - copiedMessages, - runStore: this.deps.runStore, - runtimeEventStore: this.deps.runtimeEventStore, - }); - const events = [ - ...plan.inlineRuntimeEvents, - ...plan.runs.flatMap(({ runtimeEvents }) => runtimeEvents), - ]; - if (events.some(runtimeEventHasProjectionArtifact)) { - const error = new Error( - 'Conversation copy requires artifact ownership transfer for durable Tool Result projections', - ) as Error & { code: string }; - error.code = 'conversation_copy_projection_artifact_transfer_required'; - throw error; - } - return plan; - } - - private async cloneConversationRuntimeLedger( - childSessionId: string, - copiedMessages: readonly StoredMessage[], - plan: ConversationRuntimeLedgerCopyPlan | undefined, - ): Promise { - if (!plan || !this.deps.runStore || !this.deps.runtimeEventStore) return copiedMessages; - const copied = await cloneConversationLedger({ - plan, - copiedMessages, - referenceMap: { - mode: 'preserve_external', - sourceSessionId: plan.sourceSessionId, - targetSessionId: childSessionId, - }, - runStore: this.deps.runStore, - runtimeEventStore: this.deps.runtimeEventStore, - newId: this.deps.newId, - }); - return copied.copiedMessages; - } - - private async rollbackLegacyConversationCopy(sessionId: string, error: unknown): Promise { - try { - await this.deps.store.remove(sessionId); - } catch (cleanupError) { - throw new AggregateError( - [error, cleanupError], - `Conversation copy ${sessionId} failed and could not be removed`, - ); - } - throw error; - } - /** * Closes only the two provably pre-provider crash windows owned by B2: * claim-only and target-Run-created-without-start. A repaired claim never From 4e13f3e0bcd006ab2424c030c91e8237b75f5d3d Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 10:13:26 +0800 Subject: [PATCH 14/30] test(runtime): verify durable projection owner boundaries Generated-by: Codex --- .../session-revision-two-client-uds.test.ts | 92 +++++++++++++++- .../src/__tests__/ai-sdk-backend.test.ts | 92 +++++++++++++++- .../runtime-event-read-model.test.ts | 45 -------- .../tool-runtime-durable-boundary.test.ts | 2 +- .../tool-runtime-sqlite-boundary.test.ts | 104 ++++++++++++++++-- 5 files changed, 275 insertions(+), 60 deletions(-) diff --git a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts index a77e117d70..6c09508c64 100644 --- a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts @@ -363,7 +363,7 @@ async function verifyConcurrentRevisionAuthority( }); assert.equal(artifactPage.kind, 'page'); if (artifactPage.kind !== 'page') assert.fail('Branch Artifact query must return a page'); - assert.equal(artifactPage.artifacts.length, 2); + assert.equal(artifactPage.artifacts.length, 3); assert.notEqual(artifactPage.artifacts[0]?.id, 'source-artifact'); const taskPage = await tui.request('task.ledger.query', { kind: 'list_start', @@ -881,6 +881,17 @@ async function seedSource( source: 'user_upload', now: 1, }); + const projectionArtifact = await artifacts.create({ + id: 'source-projection-artifact', + sessionId: source.id, + turnId: 'turn-1', + name: 'projection.png', + kind: 'file', + content: new Uint8Array([0x89, 0x50, 0x4e, 0x47]), + mimeType: 'image/png', + source: 'tool_result', + now: 2, + }); await artifacts.create({ id: 'legacy-child-artifact', sessionId: source.id, @@ -981,6 +992,53 @@ async function seedSource( author: 'agent', content: { kind: 'text', text: 'first response' }, }), + runtimeEvent(source.id, 'run-turn-1', 'invocation-turn-1', 'turn-1', { + id: 'projection-call', + ts: 2.1, + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'projection-tool-call', + name: 'Read', + args: { path: 'projection.png' }, + }, + }), + runtimeEvent(source.id, 'run-turn-1', 'invocation-turn-1', 'turn-1', { + id: 'projection-result', + ts: 2.2, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'projection-tool-call', + name: 'Read', + result: { + kind: 'image', + mimeType: 'image/png', + ref: { + kind: 'session_file', + sessionId: source.id, + relativePath: projectionArtifact.id, + }, + }, + modelProjection: { + version: 1, + kind: 'content', + parts: [ + { + kind: 'artifact', + mediaType: 'image/png', + ref: { + kind: 'session_file', + sessionId: source.id, + relativePath: projectionArtifact.id, + }, + }, + ], + }, + }, + }), runtimeEvent(source.id, 'run-turn-1', 'invocation-turn-1', 'turn-1', { id: 'terminal-1', ts: 2.5, @@ -1552,7 +1610,7 @@ async function verifyDurableBranch( }); }; const messages = await execution.sessionStore.readMessagesSnapshot(branchSessionId); - assert.equal(messages.length, 3); + assert.equal(messages.length, 5); const user = messages.find((message) => message.type === 'user'); assert.ok(user?.attachments?.[0]); const ref = user?.attachments?.[0]?.ref; @@ -1562,7 +1620,8 @@ async function verifyDurableBranch( // The user-upload attachment ref carries the source artifact id; the copy // must rewrite it to a fresh target artifact id, never leave the source id. assert.notEqual(ref.relativePath, 'source-artifact'); - assert.equal((await artifacts.listPage(branchSessionId, { offset: 0, limit: 10 })).total, 2); + const branchArtifacts = await artifacts.listPage(branchSessionId, { offset: 0, limit: 10 }); + assert.equal(branchArtifacts.total, 3); assert.deepEqual(await artifacts.readTextInSession(branchSessionId, ref.relativePath), { ok: true, text: 'retained bytes', @@ -1579,6 +1638,33 @@ async function verifyDurableBranch( assert.ok(copiedChild); assert.ok(copiedParent); assert.equal(copiedChild.parentRunId, copiedParent.runId); + const copiedProjectionResult = ( + await execution.runtimeEventStore.readRuntimeEvents(branchSessionId, copiedParent.runId) + ).find((event) => event.content?.kind === 'function_response'); + assert.equal(copiedProjectionResult?.content?.kind, 'function_response'); + if (copiedProjectionResult?.content?.kind !== 'function_response') { + assert.fail('Copied branch must retain the durable Tool Result projection'); + } + const copiedProjection = copiedProjectionResult.content.modelProjection; + assert.equal(copiedProjection?.kind, 'content'); + if (copiedProjection?.kind !== 'content') { + assert.fail('Copied Tool Result must retain artifact projection content'); + } + const copiedProjectionPart = copiedProjection.parts[0]; + assert.equal(copiedProjectionPart?.kind, 'artifact'); + if (copiedProjectionPart?.kind !== 'artifact') { + assert.fail('Copied Tool Result must retain its projected artifact'); + } + assert.equal(copiedProjectionPart.ref.kind, 'session_file'); + if (copiedProjectionPart.ref.kind !== 'session_file') { + assert.fail('Copied Tool Result artifact must remain Session-backed'); + } + assert.equal(copiedProjectionPart.ref.sessionId, branchSessionId); + const copiedProjectionArtifact = branchArtifacts.records.find( + (record) => record.name === 'projection.png', + ); + assert.ok(copiedProjectionArtifact); + assert.equal(copiedProjectionPart.ref.relativePath, copiedProjectionArtifact.id); assert.equal((await artifacts.listPage('revision-target', { offset: 0, limit: 10 })).total, 0); assert.deepEqual(await tasks.list('revision-target'), []); assert.deepEqual(await execution.agentRunStore.listSessionRuns('revision-target'), []); diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index fd8068dfac..9e7afeb0d3 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -20,7 +20,9 @@ import assert from 'node:assert/strict'; import { Buffer } from 'node:buffer'; import { createHash } from 'node:crypto'; -import { resolve } from 'node:path'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; import { describe, test } from 'node:test'; import type { ModelMessage, ModelStreamResult } from '../model-protocol.js'; import { MockLanguageModelV4, simulateReadableStream } from 'ai/test'; @@ -34,6 +36,7 @@ import { createManagedExecutionBoundary } from '@maka/core/sandbox-boundary'; import type { SessionHeader } from '@maka/core/session'; import type { StorageRef } from '@maka/core/events'; import { encodeCanonicalRuntimeEvent } from '@maka/core/canonical-runtime-event'; +import { createSqliteRuntimeStore } from '@maka/storage/sqlite-runtime-store'; import type { SessionEvent } from '@maka/core/events'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { @@ -3776,6 +3779,93 @@ describe('AiSdkBackend model history', () => { ); }); + test('replays the durable projection after reopening its RuntimeEvent store', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-durable-projection-restart-')); + const databasePath = join(root, 'runtime.sqlite'); + const source = createSqliteRuntimeStore(databasePath); + try { + await source.importConversationCopyRuntimeEvents('session-1', [ + { + runId: 'run-prev', + events: [ + runtimeEvent({ + id: 'restart-call', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'restart-tool-call', + name: 'PrivateTool', + args: {}, + }, + }), + runtimeEvent({ + id: 'restart-result', + turnId: 'turn-prev', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'restart-tool-call', + name: 'PrivateTool', + result: { secretExecutionFact: 'raw-secret-must-not-replay' }, + modelProjection: { + version: 1, + kind: 'text', + text: 'durable-safe-fact', + }, + }, + }), + runtimeEvent({ + id: 'restart-terminal', + turnId: 'turn-prev', + role: 'system', + author: 'system', + status: 'completed', + }), + ], + }, + ]); + } finally { + source.close(); + } + + const reopened = createSqliteRuntimeStore(databasePath); + try { + const recoveredEvents = await reopened.readRuntimeEvents('session-1', 'run-prev'); + const model = completionModel(); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'continue after restart', + context: [], + runtimeContext: recoveredEvents, + }), + ); + + const wire = JSON.stringify(compactPrompt(model)); + assert.match(wire, /durable-safe-fact/u); + assert.doesNotMatch(wire, /raw-secret-must-not-replay/u); + } finally { + reopened.close(); + await rm(root, { recursive: true, force: true }); + } + }); + test('sends a live image tool result to the next provider step', async () => { const pngBytes = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==', diff --git a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts index e49f9e3a1a..9859265b55 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -651,51 +651,6 @@ describe('projectRuntimeEventsToStoredMessages', () => { expect(replay.diagnostics).toEqual([]); }); - test('reduces live, next-turn, and cold-restart Tool Results from one durable projection', () => { - const events = [ - ev({ - id: 'evt-projected-call', - role: 'model', - author: 'agent', - content: { - kind: 'function_call', - id: 'projected-1', - name: 'PrivateTool', - args: {}, - }, - }), - ev({ - id: 'evt-projected-result', - role: 'tool', - author: 'tool', - content: { - kind: 'function_response', - id: 'projected-1', - name: 'PrivateTool', - result: { secretExecutionFact: 'must not reach the model' }, - modelProjection: { - version: 1, - kind: 'text', - text: 'stable model fact', - }, - }, - }), - ]; - - const results = [ - buildRuntimeEventModelReplayPlan(events), - buildRuntimeEventModelReplayPlan([...events]), - buildRuntimeEventModelReplayPlan(JSON.parse(JSON.stringify(events)) as RuntimeEvent[]), - ].map((plan) => plan.items.find((item) => item.kind === 'tool_result')); - const projections = results.map((result) => result?.modelProjection); - - expect(projections).toEqual([ - { version: 1, kind: 'text', text: 'stable model fact' }, - { version: 1, kind: 'text', text: 'stable model fact' }, - { version: 1, kind: 'text', text: 'stable model fact' }, - ]); - }); - test('folds retired permission modes while projecting persisted tool results', () => { const out = projectRuntimeEventsToStoredMessages( [ diff --git a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts index 257d894e76..56fb22995a 100644 --- a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts @@ -238,7 +238,7 @@ describe('ToolRuntime durable boundary', () => { ); }); - it('commits one deterministic projection fallback without repeating a completed tool', async () => { + it('commits one deterministic fallback when projection fails', async () => { let implementationCalls = 0; const outcomes: ToolOutcomeCommit[] = []; const harness = makeHarness({ diff --git a/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts index 2d8f8fd3a4..31a3574f9f 100644 --- a/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts @@ -19,6 +19,7 @@ import { createTestToolRuntime } from './execution-boundary-test-helpers.js'; import assert from 'node:assert/strict'; +import { Buffer } from 'node:buffer'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -28,7 +29,10 @@ import { type LlmConnection } from '@maka/core/llm-connections'; import { type SessionEvent } from '@maka/core/events'; import { type SessionHeader } from '@maka/core/session'; import type { McpToolBinding } from '@maka/core/mcp'; -import { createSqliteRuntimeStore } from '@maka/storage/sqlite-runtime-store'; +import { + createSqliteRuntimeStore, + type SqliteRuntimeStoreFailpoint, +} from '@maka/storage/sqlite-runtime-store'; import { createSessionEventMapMemory, mapSessionEventToRuntimeEvent, @@ -481,15 +485,6 @@ describe('ToolRuntime with real SQLite boundary', () => { kind: 'json', value: { ok: true, text: 'contents' }, }); - const nextTurnProjection = buildRuntimeEventModelReplayPlan(events).items.find( - (item) => item.kind === 'tool_result', - )?.modelProjection; - const coldRestartProjection = buildRuntimeEventModelReplayPlan( - JSON.parse(JSON.stringify(events)), - ).items.find((item) => item.kind === 'tool_result')?.modelProjection; - assert.deepEqual(nextTurnProjection, durableProjection); - assert.deepEqual(coldRestartProjection, durableProjection); - const context = invocationContext(); const memory = createSessionEventMapMemory(); const durableEvents = published.filter( @@ -518,6 +513,95 @@ describe('ToolRuntime with real SQLite boundary', () => { } }); + it('does not repeat tool or projection side effects after an atomic T2 failure', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-tool-sqlite-t2-retry-')); + let failpoint: SqliteRuntimeStoreFailpoint | undefined = 'after_runtime_event_insert'; + const store = createSqliteRuntimeStore(join(root, 'runtime.sqlite'), { + failpoint: (point) => { + if (point === failpoint) throw new Error(`sqlite runtime failpoint: ${point}`); + }, + }); + try { + let implementationCalls = 0; + let artifactWrites = 0; + const runtime = createTestToolRuntime({ + sessionId: 'session-1', + header: header(), + connection: connection(), + modelId: 'model-1', + appendMessage: async () => {}, + newId: nextId(), + now: nextNow(), + getPermissionPauseTarget: () => null, + runId: 'run-1', + invocationId: 'invocation-1', + runtimeCommitSink: store, + persistDurableProjectionArtifact: async () => { + artifactWrites += 1; + return { + kind: 'session_file', + sessionId: 'session-1', + relativePath: 'projection-artifact', + }; + }, + }); + const imageTool: MakaTool = { + name: 'Read', + description: 'read', + parameters: {}, + recoveryMode: 'replay_safe', + impl: async () => { + implementationCalls += 1; + return { private: 'completed execution fact' }; + }, + toModelOutput: () => ({ + type: 'content', + value: [ + { + type: 'file', + data: { type: 'data', data: Buffer.from([137, 80, 78, 71]).toString('base64') }, + mediaType: 'image/png', + }, + ], + }), + }; + const published: SessionEvent[] = []; + const settle = () => + runtime.settleToolCall({ + tool: imageTool, + turnId: 'turn-1', + toolCallId: 'provider-call-1', + input: {}, + abortSignal: new AbortController().signal, + eventSink: { + push: (event) => published.push(event), + pushAndWaitUntilConsumed: async (event) => { + published.push(event); + }, + }, + }); + + await assert.rejects(settle(), /sqlite runtime failpoint: after_runtime_event_insert/u); + failpoint = undefined; + await settle(); + + assert.equal(implementationCalls, 1); + assert.equal(artifactWrites, 1); + assert.equal(published.filter((event) => event.type === 'tool_result').length, 1); + const events = await store.readRuntimeEvents('session-1', 'run-1'); + assert.deepEqual( + events.map((event) => event.content?.kind), + ['function_call', undefined, 'function_response'], + ); + const operationId = events[0]?.refs?.operationId; + assert.ok(operationId); + assert.equal((await store.readToolOperation(operationId))?.currentState, 'outcome_committed'); + } finally { + store.close(); + await rm(root, { recursive: true, force: true }); + } + }); + it('persists the same normalized error event that the Runtime flow later observes', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-tool-sqlite-error-')); const store = createSqliteRuntimeStore(join(root, 'runtime.sqlite')); From 2cd07731545fc72d9d71b6f14640f8c7b4d67908 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 10:13:33 +0800 Subject: [PATCH 15/30] refactor(runtime): keep projection helper types private Generated-by: Codex --- packages/runtime/src/durable-tool-result-projection.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/durable-tool-result-projection.ts b/packages/runtime/src/durable-tool-result-projection.ts index 576a66f0ad..d14c8f2384 100644 --- a/packages/runtime/src/durable-tool-result-projection.ts +++ b/packages/runtime/src/durable-tool-result-projection.ts @@ -65,7 +65,7 @@ export function encodeDurableToolResultOutput( } } -export type DurableProjectionArtifactPersister = (input: { +type DurableProjectionArtifactPersister = (input: { bytes: Uint8Array; mediaType: string; }) => Promise; @@ -177,7 +177,7 @@ export function rewriteDurableToolResultProjectionArtifactRefs( }; } -export type EffectiveToolResultProjection = +type EffectiveToolResultProjection = | { kind: 'projection'; projection: DurableToolResultProjection; From 4afc17a7bd30b41cd35fc0f6c36eaac82b95f9b2 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 10:26:45 +0800 Subject: [PATCH 16/30] test(runtime): target durable projection T2 rollback Generated-by: Codex --- .../tool-runtime-sqlite-boundary.test.ts | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts index 31a3574f9f..dbebd74508 100644 --- a/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts @@ -515,10 +515,17 @@ describe('ToolRuntime with real SQLite boundary', () => { it('does not repeat tool or projection side effects after an atomic T2 failure', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-tool-sqlite-t2-retry-')); - let failpoint: SqliteRuntimeStoreFailpoint | undefined = 'after_runtime_event_insert'; + let runtimeEventInsertions = 0; + let failT2 = true; const store = createSqliteRuntimeStore(join(root, 'runtime.sqlite'), { failpoint: (point) => { - if (point === failpoint) throw new Error(`sqlite runtime failpoint: ${point}`); + if ( + point === ('after_runtime_event_insert' satisfies SqliteRuntimeStoreFailpoint) && + failT2 && + ++runtimeEventInsertions === 2 + ) { + throw new Error(`sqlite runtime failpoint: ${point}`); + } }, }); try { @@ -582,20 +589,22 @@ describe('ToolRuntime with real SQLite boundary', () => { }); await assert.rejects(settle(), /sqlite runtime failpoint: after_runtime_event_insert/u); - failpoint = undefined; - await settle(); + assert.equal(implementationCalls, 1); + assert.equal(artifactWrites, 1); + failT2 = false; + await assert.rejects(settle(), /duplicate_event_id/u); assert.equal(implementationCalls, 1); assert.equal(artifactWrites, 1); - assert.equal(published.filter((event) => event.type === 'tool_result').length, 1); + assert.equal(published.filter((event) => event.type === 'tool_result').length, 0); const events = await store.readRuntimeEvents('session-1', 'run-1'); assert.deepEqual( events.map((event) => event.content?.kind), - ['function_call', undefined, 'function_response'], + ['function_call', undefined], ); const operationId = events[0]?.refs?.operationId; assert.ok(operationId); - assert.equal((await store.readToolOperation(operationId))?.currentState, 'outcome_committed'); + assert.equal((await store.readToolOperation(operationId))?.currentState, 'prepared'); } finally { store.close(); await rm(root, { recursive: true, force: true }); From 55e5c745c9d072096fd97d9c951a5ef415ef9d76 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 10:26:52 +0800 Subject: [PATCH 17/30] refactor(runtime): trim projection artifact input Generated-by: Codex --- .../runtime/src/__tests__/tool-runtime-durable-boundary.test.ts | 1 - packages/runtime/src/tool-runtime.ts | 2 -- 2 files changed, 3 deletions(-) diff --git a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts index 56fb22995a..8526f13ba8 100644 --- a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts @@ -297,7 +297,6 @@ describe('ToolRuntime durable boundary', () => { persistDurableProjectionArtifact: async (input) => { order.push('artifact'); assert.equal(input.turnId, 'turn-1'); - assert.equal(input.toolCallId, 'provider-call-1'); assert.equal(input.mediaType, 'image/png'); assert.deepEqual([...input.bytes], [137, 80, 78, 71]); return artifactRef; diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index 4981afc2a2..25f9628b54 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -356,7 +356,6 @@ export interface ToolRuntimeInput { invocationId?: string; persistDurableProjectionArtifact?: (input: { turnId: string; - toolCallId: string; bytes: Uint8Array; mediaType: string; }) => Promise; @@ -785,7 +784,6 @@ export class ToolRuntime { ? ({ bytes, mediaType }) => this.input.persistDurableProjectionArtifact!({ turnId, - toolCallId, bytes, mediaType, }) From f3c16d9c752f62cbb35e3908ce27f2eb0fce5ede Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 10:30:45 +0800 Subject: [PATCH 18/30] test(runtime): produce restart projection through T2 Generated-by: Codex --- .../src/__tests__/ai-sdk-backend.test.ts | 74 ++++++++----------- 1 file changed, 32 insertions(+), 42 deletions(-) diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 9e7afeb0d3..5645674ecc 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -95,6 +95,7 @@ import { buildLlmHistorySummarizer } from '../history-compact-summarizer.js'; import { createToolResultArchiveCapability } from '../tool-result-archive-capability.js'; import { createTestAiSdkBackend, + createTestToolRuntime, readExternalExecutionBoundary, testToolResultArchive, } from './execution-boundary-test-helpers.js'; @@ -3784,49 +3785,38 @@ describe('AiSdkBackend model history', () => { const databasePath = join(root, 'runtime.sqlite'); const source = createSqliteRuntimeStore(databasePath); try { - await source.importConversationCopyRuntimeEvents('session-1', [ - { - runId: 'run-prev', - events: [ - runtimeEvent({ - id: 'restart-call', - turnId: 'turn-prev', - role: 'model', - author: 'agent', - content: { - kind: 'function_call', - id: 'restart-tool-call', - name: 'PrivateTool', - args: {}, - }, - }), - runtimeEvent({ - id: 'restart-result', - turnId: 'turn-prev', - role: 'tool', - author: 'tool', - content: { - kind: 'function_response', - id: 'restart-tool-call', - name: 'PrivateTool', - result: { secretExecutionFact: 'raw-secret-must-not-replay' }, - modelProjection: { - version: 1, - kind: 'text', - text: 'durable-safe-fact', - }, - }, - }), - runtimeEvent({ - id: 'restart-terminal', - turnId: 'turn-prev', - role: 'system', - author: 'system', - status: 'completed', - }), - ], + const runtime = createTestToolRuntime({ + sessionId: 'session-1', + header: header(), + connection: connection(), + modelId: 'mock-model-id', + appendMessage: async () => {}, + newId: idGenerator(), + now: monotonicClock(), + getPermissionPauseTarget: () => null, + turnId: 'turn-prev', + runId: 'run-prev', + invocationId: 'invocation-prev', + runtimeCommitSink: source, + }); + await runtime.settleToolCall({ + tool: { + name: 'PrivateTool', + description: 'returns a private execution fact', + parameters: z.object({}), + recoveryMode: 'replay_safe', + impl: async () => ({ secretExecutionFact: 'raw-secret-must-not-replay' }), + toModelOutput: () => ({ type: 'text', value: 'durable-safe-fact' }), + }, + turnId: 'turn-prev', + toolCallId: 'restart-tool-call', + input: {}, + abortSignal: new AbortController().signal, + eventSink: { + push: () => {}, + pushAndWaitUntilConsumed: async () => {}, }, - ]); + }); } finally { source.close(); } From 1be1a566e05555eeaddf920869ec2fed255c591a Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 11:04:35 +0800 Subject: [PATCH 19/30] style(runtime): format session manager tests Generated-by: Codex --- packages/runtime/src/__tests__/session-manager.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index e1bab6db37..b1b9bb7e12 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -11465,7 +11465,6 @@ describe('SessionManager permission mode updates', () => { const [turn] = await store.listTurns(session.id); expect(turn?.errorClass).toBe('app_restarted'); }); - }); async function drainAll(iterable: AsyncIterable): Promise { From f370c3655237467a99711bc5a489440eb25ddb91 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 11:29:31 +0800 Subject: [PATCH 20/30] fix(runtime): make projection admission side-effect free Generated-by: Codex --- .../durable-tool-result-projection.test.ts | 46 +++++++ .../src/durable-tool-result-projection.ts | 30 +++- .../src/server/execution-model-composition.ts | 8 +- .../durable-tool-result-projection.test.ts | 114 +++++++++++++++- .../execution-boundary-test-helpers.ts | 15 +- .../tool-runtime-durable-boundary.test.ts | 10 +- .../tool-runtime-sqlite-boundary.test.ts | 14 +- packages/runtime/src/ai-sdk-backend.ts | 6 +- .../src/durable-tool-result-projection.ts | 129 ++++++++++++------ packages/runtime/src/tool-runtime.ts | 11 +- .../__tests__/artifact-attachments.test.ts | 29 ++++ packages/storage/src/artifact-attachments.ts | 105 ++++++++++---- packages/storage/src/artifact-stores.ts | 2 + 13 files changed, 413 insertions(+), 106 deletions(-) diff --git a/packages/core/src/__tests__/durable-tool-result-projection.test.ts b/packages/core/src/__tests__/durable-tool-result-projection.test.ts index 58e5e2fb44..9726ccbaa9 100644 --- a/packages/core/src/__tests__/durable-tool-result-projection.test.ts +++ b/packages/core/src/__tests__/durable-tool-result-projection.test.ts @@ -23,6 +23,8 @@ import { describe, it } from 'node:test'; import { decodeDurableToolResultProjection, DURABLE_TOOL_RESULT_PROJECTION_MAX_BYTES, + DURABLE_TOOL_RESULT_PROJECTION_MAX_JSON_DEPTH, + DURABLE_TOOL_RESULT_PROJECTION_MAX_JSON_NODES, } from '../durable-tool-result-projection.js'; describe('durable Tool Result projection', () => { @@ -111,4 +113,48 @@ describe('durable Tool Result projection', () => { /Invalid durable Tool Result projection/, ); }); + + it('admits only canonical safe image media types', () => { + const projection = (mediaType: string) => ({ + version: 1, + kind: 'content', + parts: [ + { + kind: 'artifact', + mediaType, + ref: { kind: 'session_file', sessionId: 'session-1', relativePath: 'artifact-1' }, + }, + ], + }); + + assert.equal(decodeDurableToolResultProjection(projection('image/png')).kind, 'content'); + assert.throws( + () => decodeDurableToolResultProjection(projection('image/png; token=sk-secret')), + /Invalid durable Tool Result projection/, + ); + assert.throws( + () => decodeDurableToolResultProjection(projection('image/svg+xml')), + /Invalid durable Tool Result projection/, + ); + }); + + it('applies the same bounded JSON budget to persisted projections', () => { + let tooDeep: unknown = 'leaf'; + for (let depth = 0; depth <= DURABLE_TOOL_RESULT_PROJECTION_MAX_JSON_DEPTH; depth += 1) { + tooDeep = [tooDeep]; + } + assert.throws( + () => decodeDurableToolResultProjection({ version: 1, kind: 'json', value: tooDeep }), + /Invalid durable Tool Result projection/, + ); + assert.throws( + () => + decodeDurableToolResultProjection({ + version: 1, + kind: 'json', + value: Array.from({ length: DURABLE_TOOL_RESULT_PROJECTION_MAX_JSON_NODES }, () => null), + }), + /Invalid durable Tool Result projection/, + ); + }); }); diff --git a/packages/core/src/durable-tool-result-projection.ts b/packages/core/src/durable-tool-result-projection.ts index 67a0f9fe18..3a5a397597 100644 --- a/packages/core/src/durable-tool-result-projection.ts +++ b/packages/core/src/durable-tool-result-projection.ts @@ -18,13 +18,15 @@ */ import { isCanonicalStorageRef, type StorageRef } from './events.js'; -import { isCanonicalArtifactEntityId } from './artifacts.js'; +import { isCanonicalArtifactEntityId, normalizeArtifactImagePreviewMime } from './artifacts.js'; import { hasExactShape, isRecord } from './record-schema.js'; import { serializedByteLength } from './serialized-byte-length.js'; export const DURABLE_TOOL_RESULT_PROJECTION_VERSION = 1 as const; export const DURABLE_TOOL_RESULT_PROJECTION_MAX_BYTES = 256 * 1024; export const DURABLE_TOOL_RESULT_PROJECTION_MAX_PARTS = 64; +export const DURABLE_TOOL_RESULT_PROJECTION_MAX_JSON_DEPTH = 32; +export const DURABLE_TOOL_RESULT_PROJECTION_MAX_JSON_NODES = 20_000; export const DURABLE_TOOL_RESULT_PROJECTION_FAILURE_MESSAGE = 'The tool completed, but its model-visible result could not be projected safely.'; @@ -97,7 +99,7 @@ function isDurableToolResultProjection(value: unknown): value is DurableToolResu required: ['version', 'kind', 'value'], allowed: new Set(['version', 'kind', 'value', 'isError']), }) && - isDurableProjectionJson(value.value) && + isDurableProjectionJson(value.value, { nodes: 0 }, 0) && (value.isError === undefined || value.isError === true) ); case 'content': @@ -150,14 +152,25 @@ function isProjectionPart(value: unknown): value is DurableToolResultProjectionP allowed: new Set(['kind', 'mediaType', 'ref']), }) && typeof value.mediaType === 'string' && - value.mediaType.length > 0 && + normalizeArtifactImagePreviewMime(value.mediaType) === value.mediaType && isCanonicalStorageRef(value.ref) && (value.ref.kind === 'session_context' || (value.ref.kind === 'session_file' && isCanonicalArtifactEntityId(value.ref.relativePath))) ); } -function isDurableProjectionJson(value: unknown): value is DurableProjectionJson { +function isDurableProjectionJson( + value: unknown, + state: { nodes: number }, + depth: number, +): value is DurableProjectionJson { + state.nodes += 1; + if ( + state.nodes > DURABLE_TOOL_RESULT_PROJECTION_MAX_JSON_NODES || + depth > DURABLE_TOOL_RESULT_PROJECTION_MAX_JSON_DEPTH + ) { + return false; + } if ( value === null || typeof value === 'string' || @@ -166,6 +179,11 @@ function isDurableProjectionJson(value: unknown): value is DurableProjectionJson ) { return true; } - if (Array.isArray(value)) return value.every(isDurableProjectionJson); - return isRecord(value) && Object.values(value).every(isDurableProjectionJson); + if (Array.isArray(value)) { + return value.every((item) => isDurableProjectionJson(item, state, depth + 1)); + } + return ( + isRecord(value) && + Object.values(value).every((item) => isDurableProjectionJson(item, state, depth + 1)) + ); } diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index 3e2b65752f..3408e5cf50 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -48,7 +48,7 @@ import { type BackendFactoryContext } from '@maka/runtime/session-manager'; import { type RuntimeCommitSink } from '@maka/runtime/runtime-commit-sink'; import { createAttachmentByteReader, - createReadImageSnapshotter, + createReadImageSnapshotPlanner, persistProviderRequestCaptureArtifact, type InteractiveArtifactStoreWriter, } from '@maka/storage/artifact-stores'; @@ -333,7 +333,7 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom ); } : undefined; - const persistProjectionImage = createReadImageSnapshotter(input.artifacts); + const planProjectionImage = createReadImageSnapshotPlanner(input.artifacts); try { return new HostAiSdkBackend( @@ -400,8 +400,8 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom ? { readImageSnapshotsUnavailable: true } : {}), }), - persistDurableProjectionArtifact: ({ turnId, bytes, mediaType }) => - persistProjectionImage({ + prepareDurableProjectionArtifact: ({ turnId, bytes, mediaType }) => + planProjectionImage({ sessionId: input.context.sessionId, turnId, name: 'Tool Result image', diff --git a/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts b/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts index e610a8c3b6..08329d29a2 100644 --- a/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts +++ b/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts @@ -19,6 +19,8 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; +import { DURABLE_TOOL_RESULT_PROJECTION_MAX_BYTES } from '@maka/core/durable-tool-result-projection'; +import { serializedByteLength } from '@maka/core/serialized-byte-length'; import { decodeEffectiveToolResultProjection, @@ -140,14 +142,101 @@ describe('durable Tool Result projection codec', () => { ], }, 'session-1', - async () => { + artifactPlanner(() => { writes += 1; - return { - kind: 'session_file', - sessionId: 'session-1', - relativePath: 'artifact-1', - }; + }), + ); + + assert.equal(projection.kind, 'failure'); + assert.equal(writes, 0); + }); + + it('validates the complete projection before persisting any artifact', async () => { + let writes = 0; + const projection = await encodeDurableToolResultOutputWithArtifacts( + { + type: 'content', + value: [ + ...Array.from({ length: 64 }, (_, index) => ({ + type: 'text' as const, + text: `part-${index}`, + })), + { + type: 'file', + data: { type: 'data' as const, data: Buffer.from('valid').toString('base64') }, + mediaType: 'image/png', + }, + ], }, + 'session-1', + artifactPlanner(() => { + writes += 1; + }), + ); + + assert.equal(projection.kind, 'failure'); + assert.equal(writes, 0); + }); + + it('uses the exact planned artifact ref as the projection size authority', async () => { + const ref = { + kind: 'session_file' as const, + sessionId: 'session-1', + relativePath: 'artifact-1', + }; + const emptyProjection = { + version: 1 as const, + kind: 'content' as const, + parts: [ + { kind: 'text' as const, text: '' }, + { kind: 'artifact' as const, mediaType: 'image/png', ref }, + ], + }; + const textLength = + DURABLE_TOOL_RESULT_PROJECTION_MAX_BYTES - serializedByteLength(emptyProjection); + let writes = 0; + const projection = await encodeDurableToolResultOutputWithArtifacts( + { + type: 'content', + value: [ + { type: 'text', text: 'x'.repeat(textLength) }, + { + type: 'file', + data: { type: 'data', data: Buffer.from('valid').toString('base64') }, + mediaType: 'image/png', + }, + ], + }, + 'session-1', + () => ({ + ref, + persist: async () => { + writes += 1; + }, + }), + ); + + assert.equal(projection.kind, 'content'); + assert.equal(writes, 1); + }); + + it('rejects unsafe image metadata before persisting it', async () => { + let writes = 0; + const projection = await encodeDurableToolResultOutputWithArtifacts( + { + type: 'content', + value: [ + { + type: 'file', + data: { type: 'data', data: Buffer.from('valid').toString('base64') }, + mediaType: 'image/png; token=sk-secret', + }, + ], + }, + 'session-1', + artifactPlanner(() => { + writes += 1; + }), ); assert.equal(projection.kind, 'failure'); @@ -179,3 +268,16 @@ describe('durable Tool Result projection codec', () => { ); }); }); + +function artifactPlanner(onPersist: () => void) { + let nextId = 0; + return () => { + const relativePath = `artifact-${++nextId}`; + return { + ref: { kind: 'session_file' as const, sessionId: 'session-1', relativePath }, + persist: async () => { + onPersist(); + }, + }; + }; +} diff --git a/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts b/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts index ef48ee2fba..a40de02125 100644 --- a/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts +++ b/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts @@ -44,13 +44,18 @@ export function createTestAiSdkBackend(input: TestAiSdkBackendInput): AiSdkBacke ...backendInput, ...(testProjectionArtifacts ? { - persistDurableProjectionArtifact: async ({ bytes }: { bytes: Uint8Array }) => { + prepareDurableProjectionArtifact: ({ bytes }: { bytes: Uint8Array }) => { const relativePath = `artifact-${++nextArtifactId}`; - artifacts.set(relativePath, bytes.slice()); + const accepted = bytes.slice(); return { - kind: 'session_file' as const, - sessionId: input.sessionId, - relativePath, + ref: { + kind: 'session_file' as const, + sessionId: input.sessionId, + relativePath, + }, + persist: async () => { + artifacts.set(relativePath, accepted); + }, }; }, readAttachmentBytes: diff --git a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts index 8526f13ba8..2986928531 100644 --- a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts @@ -294,12 +294,16 @@ describe('ToolRuntime durable boundary', () => { undefined, 'run-1', { - persistDurableProjectionArtifact: async (input) => { - order.push('artifact'); + prepareDurableProjectionArtifact: (input) => { assert.equal(input.turnId, 'turn-1'); assert.equal(input.mediaType, 'image/png'); assert.deepEqual([...input.bytes], [137, 80, 78, 71]); - return artifactRef; + return { + ref: artifactRef, + persist: async () => { + order.push('artifact'); + }, + }; }, }, ); diff --git a/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts index dbebd74508..cd9b785669 100644 --- a/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts @@ -543,12 +543,16 @@ describe('ToolRuntime with real SQLite boundary', () => { runId: 'run-1', invocationId: 'invocation-1', runtimeCommitSink: store, - persistDurableProjectionArtifact: async () => { - artifactWrites += 1; + prepareDurableProjectionArtifact: () => { return { - kind: 'session_file', - sessionId: 'session-1', - relativePath: 'projection-artifact', + ref: { + kind: 'session_file', + sessionId: 'session-1', + relativePath: 'projection-artifact', + }, + persist: async () => { + artifactWrites += 1; + }, }; }, }); diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 1c4d1e8f87..b51269200d 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -803,8 +803,8 @@ export interface AiSdkBackendInput extends AiSdkCompactionCapabilities { * Caller wires this to the session ArtifactStore; runtime never imports storage. */ readAttachmentBytes?: AttachmentByteReader; - /** Host-owned persistence for inline image parts before their Tool Result T2 commit. */ - persistDurableProjectionArtifact?: ToolRuntimeInput['persistDurableProjectionArtifact']; + /** Host-owned exact ref plan for inline images, persisted only after projection validation. */ + prepareDurableProjectionArtifact?: ToolRuntimeInput['prepareDurableProjectionArtifact']; /** * Whether the selected model accepts image input. Only explicit true sends * image parts; false/unknown stay as text refs with a fallback note. @@ -1358,7 +1358,7 @@ export class AiSdkBackend implements AgentBackend { ...(identity.runId ? { runId: identity.runId } : {}), orchestrationMode: identity.orchestrationMode, ...(identity.invocationId ? { invocationId: identity.invocationId } : {}), - persistDurableProjectionArtifact: input.persistDurableProjectionArtifact, + prepareDurableProjectionArtifact: input.prepareDurableProjectionArtifact, spawnChildSession: input.spawnChildSession, listChildAgents: input.listChildAgents, readChildAgentOutput: input.readChildAgentOutput, diff --git a/packages/runtime/src/durable-tool-result-projection.ts b/packages/runtime/src/durable-tool-result-projection.ts index d14c8f2384..3ca67d3ae9 100644 --- a/packages/runtime/src/durable-tool-result-projection.ts +++ b/packages/runtime/src/durable-tool-result-projection.ts @@ -20,6 +20,8 @@ import { decodeDurableToolResultProjection, DURABLE_TOOL_RESULT_PROJECTION_FAILURE, + DURABLE_TOOL_RESULT_PROJECTION_MAX_JSON_DEPTH, + DURABLE_TOOL_RESULT_PROJECTION_MAX_JSON_NODES, DURABLE_TOOL_RESULT_PROJECTION_MAX_PARTS, DURABLE_TOOL_RESULT_PROJECTION_VERSION, type DurableProjectionArtifactRef, @@ -28,7 +30,10 @@ import { type DurableToolResultProjectionPart, } from '@maka/core/durable-tool-result-projection'; import { MAX_READ_IMAGE_BYTES } from '@maka/core/attachments'; -import { isCanonicalArtifactEntityId } from '@maka/core/artifacts'; +import { + isCanonicalArtifactEntityId, + normalizeArtifactImagePreviewMime, +} from '@maka/core/artifacts'; import { isCanonicalStorageRef } from '@maka/core/events'; import type { ToolResultContent } from '@maka/core/events'; import { redactSecrets } from '@maka/core/redaction'; @@ -45,8 +50,6 @@ import { projectFileWriteToolResultForModel } from './file-tool-model-output.js' const OMITTED_BINARY_TEXT = '[Binary tool output omitted from the durable model projection; repeat the tool call if it is still needed.]'; -const MAX_JSON_DEPTH = 32; -const MAX_JSON_NODES = 20_000; /** * The only new-write codec from Runtime's tool-output contract into the @@ -65,39 +68,34 @@ export function encodeDurableToolResultOutput( } } -type DurableProjectionArtifactPersister = (input: { +interface DurableProjectionArtifactPlan { + ref: Extract; + persist(): Promise; +} + +type DurableProjectionArtifactPlanner = (input: { bytes: Uint8Array; mediaType: string; -}) => Promise; +}) => DurableProjectionArtifactPlan; export function encodeDurableToolResultOutputWithArtifacts( output: ToolResultOutput, sessionId: string, - persistArtifact: DurableProjectionArtifactPersister | undefined, + planArtifact: DurableProjectionArtifactPlanner | undefined, ): DurableToolResultProjection | PromiseLike { - if (!persistArtifact || output.type !== 'content' || !hasInlineImage(output)) { + if (!planArtifact || output.type !== 'content' || !hasInlineImage(output)) { return encodeDurableToolResultOutput(output, sessionId); } return (async () => { try { - const decodedImages = output.value.map((part) => - part.type === 'file' && - part.data.type === 'data' && - part.mediaType.toLowerCase().startsWith('image/') - ? decodeBoundedImageData(part.data.data) - : undefined, - ); - const value: Extract['value'] = []; - for (const [index, part] of output.value.entries()) { - const bytes = decodedImages[index]; - if (!bytes || part.type !== 'file') { - value.push(part); - continue; - } - const ref = await persistArtifact({ bytes, mediaType: part.mediaType }); - value.push({ ...part, data: { ref } } as never); + const prepared = prepareContentProjection(output, sessionId, planArtifact); + const persisted = new Set(); + for (const artifact of prepared.artifacts) { + if (persisted.has(artifact.ref.relativePath)) continue; + await artifact.persist(); + persisted.add(artifact.ref.relativePath); } - return encodeDurableToolResultOutput({ type: 'content', value }, sessionId); + return prepared.projection; } catch { return DURABLE_TOOL_RESULT_PROJECTION_FAILURE; } @@ -111,10 +109,12 @@ export function encodeDefaultDurableToolResultOutput( const image = sessionImageResult(result, sessionId); if (image) { try { + const mediaType = normalizeArtifactImagePreviewMime(image.mimeType); + if (!mediaType) throw new Error('Image has an unsafe media type'); return decodeDurableToolResultProjection({ version: DURABLE_TOOL_RESULT_PROJECTION_VERSION, kind: 'content', - parts: [{ kind: 'artifact', mediaType: image.mimeType, ref: image.ref }], + parts: [{ kind: 'artifact', mediaType, ref: image.ref }], }); } catch { return DURABLE_TOOL_RESULT_PROJECTION_FAILURE; @@ -295,25 +295,63 @@ function encodeOutput(output: ToolResultOutput, sessionId: string): DurableToolR ...(output.reason !== undefined ? { reason: redactSecrets(output.reason) } : {}), }; case 'content': { - if (output.value.length > DURABLE_TOOL_RESULT_PROJECTION_MAX_PARTS) { - throw new Error('Tool Result content exceeds the durable part limit'); - } - const parts: DurableToolResultProjectionPart[] = []; - for (const part of output.value) { - if (part.type === 'text') { - parts.push({ kind: 'text', text: redactSecrets(part.text) }); - continue; - } - if (part.type === 'file') { - const ref = readSessionArtifactRef(part, sessionId); - if (ref) parts.push({ kind: 'artifact', mediaType: part.mediaType, ref }); - else parts.push({ kind: 'text', text: OMITTED_BINARY_TEXT }); - } - } - if (parts.length === 0) parts.push({ kind: 'text', text: 'Tool completed with no content.' }); - return { version: DURABLE_TOOL_RESULT_PROJECTION_VERSION, kind: 'content', parts }; + return prepareContentProjection(output, sessionId).projection; + } + } +} + +function prepareContentProjection( + output: Extract, + sessionId: string, + planArtifact?: DurableProjectionArtifactPlanner, +): { + projection: DurableToolResultProjection; + artifacts: DurableProjectionArtifactPlan[]; +} { + if (output.value.length > DURABLE_TOOL_RESULT_PROJECTION_MAX_PARTS) { + throw new Error('Tool Result content exceeds the durable part limit'); + } + const parts: DurableToolResultProjectionPart[] = []; + const artifacts: DurableProjectionArtifactPlan[] = []; + for (const part of output.value) { + if (part.type === 'text') { + parts.push({ kind: 'text', text: redactSecrets(part.text) }); + continue; + } + if (part.type !== 'file') continue; + const ref = readSessionArtifactRef(part, sessionId); + if (ref) { + const mediaType = normalizeArtifactImagePreviewMime(part.mediaType); + if (!mediaType) throw new Error('Artifact has an unsafe media type'); + parts.push({ kind: 'artifact', mediaType, ref }); + continue; } + if ( + planArtifact && + part.data.type === 'data' && + part.mediaType.toLowerCase().startsWith('image/') + ) { + const mediaType = normalizeArtifactImagePreviewMime(part.mediaType); + if (!mediaType) throw new Error('Inline image has an unsafe media type'); + const artifact = planArtifact({ + bytes: decodeBoundedImageData(part.data.data), + mediaType, + }); + artifacts.push(artifact); + parts.push({ kind: 'artifact', mediaType, ref: artifact.ref }); + continue; + } + parts.push({ kind: 'text', text: OMITTED_BINARY_TEXT }); } + if (parts.length === 0) parts.push({ kind: 'text', text: 'Tool completed with no content.' }); + return { + projection: decodeDurableToolResultProjection({ + version: DURABLE_TOOL_RESULT_PROJECTION_VERSION, + kind: 'content', + parts, + }), + artifacts, + }; } function readSessionArtifactRef( @@ -393,7 +431,12 @@ function sanitizeJsonValue( depth: number, ): DurableProjectionJson { state.nodes += 1; - if (state.nodes > MAX_JSON_NODES || depth > MAX_JSON_DEPTH) throw new Error('JSON exceeds limit'); + if ( + state.nodes > DURABLE_TOOL_RESULT_PROJECTION_MAX_JSON_NODES || + depth > DURABLE_TOOL_RESULT_PROJECTION_MAX_JSON_DEPTH + ) { + throw new Error('JSON exceeds limit'); + } if (value === null) return null; if (typeof value === 'string') return value; if (typeof value === 'boolean') return value; diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index 25f9628b54..b1bfa4c8e8 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -354,11 +354,14 @@ export interface ToolRuntimeInput { runId?: string; orchestrationMode?: OrchestrationMode; invocationId?: string; - persistDurableProjectionArtifact?: (input: { + prepareDurableProjectionArtifact?: (input: { turnId: string; bytes: Uint8Array; mediaType: string; - }) => Promise; + }) => { + ref: Extract; + persist(): Promise; + }; spawnChildSession?: (input: { parentRunId: string; parentTurnId: string; @@ -780,9 +783,9 @@ export class ToolRuntime { encodeDurableToolResultOutputWithArtifacts( resolved, this.input.sessionId, - this.input.persistDurableProjectionArtifact + this.input.prepareDurableProjectionArtifact ? ({ bytes, mediaType }) => - this.input.persistDurableProjectionArtifact!({ + this.input.prepareDurableProjectionArtifact!({ turnId, bytes, mediaType, diff --git a/packages/storage/src/__tests__/artifact-attachments.test.ts b/packages/storage/src/__tests__/artifact-attachments.test.ts index 082783e0dc..3f9bba3937 100644 --- a/packages/storage/src/__tests__/artifact-attachments.test.ts +++ b/packages/storage/src/__tests__/artifact-attachments.test.ts @@ -28,6 +28,7 @@ import { type StorageRef } from '@maka/core/events'; import { createArtifactAttachmentResourceReader, createAttachmentByteReader, + createReadImageSnapshotPlanner, createReadImageSnapshotter, } from '../artifact-attachments.js'; import { createSqliteArtifactStore as createArtifactStore } from '../artifact-store.js'; @@ -239,6 +240,34 @@ describe('artifact attachment authority', () => { assert.equal((await store.list('session-1')).length, 1); }); }); + + test('planner derives the final ref without publishing before commit', async () => { + await withStore(async (store) => { + const bytes = png.slice(); + const input = { + sessionId: 'session-1', + turnId: 'turn-1', + name: 'Tool Result image', + bytes, + mimeType: 'image/png', + }; + const plan = createReadImageSnapshotPlanner(store)(input); + + assert.deepEqual(await store.list('session-1'), []); + bytes[0] = 0; + input.name = 'mutated after prepare'; + await Promise.all([plan.persist(), plan.persist()]); + assert.deepEqual( + (await store.list('session-1')).map((artifact) => artifact.id), + [plan.ref.relativePath], + ); + assert.deepEqual(await store.readBinary(plan.ref.relativePath), { + ok: true, + base64: Buffer.from(png).toString('base64'), + mimeType: 'image/png', + }); + }); + }); }); function sessionFileRef(relativePath: string, sessionId = 'session-1'): StorageRef { diff --git a/packages/storage/src/artifact-attachments.ts b/packages/storage/src/artifact-attachments.ts index 7008296463..867b086d1c 100644 --- a/packages/storage/src/artifact-attachments.ts +++ b/packages/storage/src/artifact-attachments.ts @@ -23,6 +23,11 @@ import { READ_IMAGE_TOO_LARGE_MESSAGE, type AttachmentByteReader, } from '@maka/core/attachments'; +import { + isArtifactTurnKey, + isCanonicalArtifactEntityId, + normalizeArtifactImagePreviewMime, +} from '@maka/core/artifacts'; import { createHash } from 'node:crypto'; import { type StorageRef, type ToolResultContent } from '@maka/core/events'; import type { ReadImageSnapshotReader } from '@maka/core/context-offload'; @@ -31,6 +36,7 @@ import type { ArtifactStore, DurableArtifactAttachmentReader, } from './artifact-store.js'; +import { sanitizeArtifactName } from './artifact-store.js'; export interface ArtifactAttachmentResourceReader { readAttachmentResource( @@ -120,42 +126,87 @@ export function createAttachmentByteReader(input: { }; } -export function createReadImageSnapshotter(artifactStore: Pick) { - return async (input: { - sessionId: string; - turnId: string; - name: string; - bytes: Uint8Array; - mimeType: string; - }): Promise> => { +interface ReadImageSnapshotInput { + sessionId: string; + turnId: string; + name: string; + bytes: Uint8Array; + mimeType: string; +} + +export interface ReadImageSnapshotPlan { + ref: Extract; + persist(): Promise; +} + +export function createReadImageSnapshotPlanner(artifactStore: Pick) { + return (input: ReadImageSnapshotInput): ReadImageSnapshotPlan => { if (input.bytes.byteLength > MAX_READ_IMAGE_BYTES) { throw new Error(READ_IMAGE_TOO_LARGE_MESSAGE); } + if (normalizeArtifactImagePreviewMime(input.mimeType) !== input.mimeType) { + throw new Error('Image media type is not canonical or safe'); + } + if (!isCanonicalArtifactEntityId(input.sessionId)) { + throw new Error('Image Session id is not canonical'); + } + if (!isArtifactTurnKey(input.turnId)) { + throw new Error('Image turn id is not canonical'); + } + const accepted = Object.freeze({ + sessionId: input.sessionId, + turnId: input.turnId, + name: sanitizeArtifactName(input.name), + bytes: input.bytes.slice(), + mimeType: input.mimeType, + }); const id = `image_${createHash('sha256') - .update(input.sessionId, 'utf8') + .update(accepted.sessionId, 'utf8') .update('\0', 'utf8') - .update(input.turnId, 'utf8') + .update(accepted.turnId, 'utf8') .update('\0', 'utf8') - .update(input.name, 'utf8') + .update(accepted.name, 'utf8') .update('\0', 'utf8') - .update(input.mimeType, 'utf8') + .update(accepted.mimeType, 'utf8') .update('\0', 'utf8') - .update(input.bytes) + .update(accepted.bytes) .digest('hex')}`; - const artifact = await artifactStore.create({ - id, - sessionId: input.sessionId, - turnId: input.turnId, - name: input.name, - kind: 'image', - content: input.bytes, - mimeType: input.mimeType, - source: 'tool_result', + let publication: Promise | undefined; + const ref = Object.freeze({ + kind: 'session_file' as const, + sessionId: accepted.sessionId, + relativePath: id, }); - return { - kind: 'session_file', - sessionId: input.sessionId, - relativePath: artifact.id, - }; + return Object.freeze({ + ref, + persist() { + publication ??= artifactStore + .create({ + id, + sessionId: accepted.sessionId, + turnId: accepted.turnId, + name: accepted.name, + kind: 'image', + content: accepted.bytes, + mimeType: accepted.mimeType, + source: 'tool_result', + }) + .then((artifact) => { + if (artifact.id !== id) throw new Error('Artifact publication changed its planned id'); + }); + return publication; + }, + }); + }; +} + +export function createReadImageSnapshotter(artifactStore: Pick) { + const planSnapshot = createReadImageSnapshotPlanner(artifactStore); + return async ( + input: ReadImageSnapshotInput, + ): Promise> => { + const plan = planSnapshot(input); + await plan.persist(); + return plan.ref; }; } diff --git a/packages/storage/src/artifact-stores.ts b/packages/storage/src/artifact-stores.ts index 99a5e1f232..9fbfa807da 100644 --- a/packages/storage/src/artifact-stores.ts +++ b/packages/storage/src/artifact-stores.ts @@ -41,8 +41,10 @@ import { export { createArtifactAttachmentResourceReader, createAttachmentByteReader, + createReadImageSnapshotPlanner, createReadImageSnapshotter, type ArtifactAttachmentResourceReader, + type ReadImageSnapshotPlan, } from './artifact-attachments.js'; export { persistProviderRequestCaptureArtifact } from './provider-request-capture-artifact.js'; From d3307053a778e32c18b021cc9828399989b739d0 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 11:29:31 +0800 Subject: [PATCH 21/30] test(runtime): replay persisted image after restart Generated-by: Codex --- .../src/__tests__/ai-sdk-backend.test.ts | 53 +++++++++++++++++-- 1 file changed, 49 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 5645674ecc..63fac89ace 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -3780,9 +3780,14 @@ describe('AiSdkBackend model history', () => { ); }); - test('replays the durable projection after reopening its RuntimeEvent store', async () => { + test('replays a persisted image projection after reopening its RuntimeEvent store', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-durable-projection-restart-')); const databasePath = join(root, 'runtime.sqlite'); + const pngBytes = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==', + 'base64', + ); + const artifacts = new Map(); const source = createSqliteRuntimeStore(databasePath); try { const runtime = createTestToolRuntime({ @@ -3798,6 +3803,20 @@ describe('AiSdkBackend model history', () => { runId: 'run-prev', invocationId: 'invocation-prev', runtimeCommitSink: source, + prepareDurableProjectionArtifact: ({ bytes, mediaType }) => { + assert.equal(mediaType, 'image/png'); + const accepted = bytes.slice(); + return { + ref: { + kind: 'session_file', + sessionId: 'session-1', + relativePath: 'artifact-1', + }, + persist: async () => { + artifacts.set('artifact-1', accepted); + }, + }; + }, }); await runtime.settleToolCall({ tool: { @@ -3806,7 +3825,16 @@ describe('AiSdkBackend model history', () => { parameters: z.object({}), recoveryMode: 'replay_safe', impl: async () => ({ secretExecutionFact: 'raw-secret-must-not-replay' }), - toModelOutput: () => ({ type: 'text', value: 'durable-safe-fact' }), + toModelOutput: () => ({ + type: 'content', + value: [ + { + type: 'file', + data: { type: 'data', data: pngBytes.toString('base64') }, + mediaType: 'image/png', + }, + ], + }), }, turnId: 'turn-prev', toolCallId: 'restart-tool-call', @@ -3834,6 +3862,14 @@ describe('AiSdkBackend model history', () => { modelId: 'mock-model-id', modelFactory: () => model, tools: [], + supportsVision: true, + maxProviderImageRequestBytes: pngBytes.byteLength, + readAttachmentBytes: async (ref) => { + const bytes = ref.kind === 'session_file' ? artifacts.get(ref.relativePath) : undefined; + return bytes + ? { ok: true, bytes: bytes.slice() } + : { ok: false, reason: 'not_found' as const }; + }, newId: idGenerator(), now: monotonicClock(), }); @@ -3847,8 +3883,17 @@ describe('AiSdkBackend model history', () => { }), ); - const wire = JSON.stringify(compactPrompt(model)); - assert.match(wire, /durable-safe-fact/u); + const prompt = compactPrompt(model) as Array<{ role: string; content: any[] }>; + const result = prompt.find((message) => message.role === 'tool')?.content[0]?.output; + assert.ok( + result.value.some( + (part: any) => + part.type === 'file' && + part.mediaType === 'image/png' && + part.data.data === pngBytes.toString('base64'), + ), + ); + const wire = JSON.stringify(prompt); assert.doesNotMatch(wire, /raw-secret-must-not-replay/u); } finally { reopened.close(); From 8051b0d6abb5ee0e973331b481db6f8cab02a49b Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 11:48:01 +0800 Subject: [PATCH 22/30] test(runtime-host): cover projection artifact restart Generated-by: Codex --- .../execution-model-composition.test.ts | 165 +++++++++++++++++- .../__tests__/artifact-attachments.test.ts | 4 +- 2 files changed, 166 insertions(+), 3 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 e7a74b52a6..abf9710cbd 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -573,6 +573,146 @@ test('backend creation admits an enabled model a snapshot never listed', async ( await backend.dispose(); }); +test('Host reopens one projected image from its ArtifactStore authority', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-host-projection-image-')); + const capability = await resolveStorageRoot({ + path: join(base, 'interactive'), + kind: 'interactive', + }); + const runtimePath = join(base, 'runtime.sqlite'); + const pngBytes = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==', + 'base64', + ); + const sessionId = 'backend-creation-session'; + const runId = 'projection-image-run'; + const turnId = 'projection-image-turn'; + const head: RuntimeEvent = { + id: 'projection-image-head', + invocationId: runId, + runId, + sessionId, + turnId, + ts: 1, + partial: false, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'Return the projected image.' }, + }; + let owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + const provider = await startProvider(); + provider.configureProjectionImageFlow('ProjectedImage'); + const assertProjectedImage = (body: Record | undefined) => { + assert.ok(body); + assert.doesNotMatch(JSON.stringify(body), /raw execution fact/u); + assert.deepEqual(JSON.parse(latestToolResultText(body) ?? 'null'), [ + { + type: 'file', + mediaType: 'image/png', + data: { type: 'data', data: pngBytes.toString('base64') }, + }, + ]); + }; + let backend: Awaited> | undefined; + let artifacts: Awaited> | undefined; + let runtime = createSqliteRuntimeStore(runtimePath); + try { + artifacts = await openInteractiveArtifactStoreForWrite(owner.lease); + await artifacts.recover(); + await runtime.appendRuntimeEvent(sessionId, runId, head); + backend = await createHostAiSdkBackend( + backendCreationFixture({ + abortSignal: new AbortController().signal, + resolveExecutionConnection: async () => + readyExecutionConnection(provider.baseUrl, { vision: true }), + readPricing: async () => ({ revision: 0, overrides: [] }), + executionBoundary: createBypassExecutionBoundary(0), + tools: [ + { + name: 'ProjectedImage', + description: 'Return one inline image.', + parameters: z.object({}), + recoveryMode: 'replay_safe', + impl: async () => ({ private: 'raw execution fact' }), + toModelOutput: () => ({ + type: 'content', + value: [ + { + type: 'file', + data: { type: 'data', data: pngBytes.toString('base64') }, + mediaType: 'image/png', + }, + ], + }), + }, + ], + artifacts, + loadTurnRuntimeEvents: () => runtime.readImmutableRuntimeEvents(sessionId, runId), + runtimeCommitSink: runtime, + }), + ); + for await (const _event of backend.send({ + invocationId: runId, + runId, + turnId, + headAnchorRuntimeEvent: head, + text: 'Return the projected image.', + context: [], + runtimeContext: [head], + })) { + // Drain the complete live tool step. + } + const liveRequests = provider.requests.filter((request) => request.body.stream === true); + assert.equal(liveRequests.length, 2); + assertProjectedImage(liveRequests[1]?.body); + await backend.dispose(); + backend = undefined; + artifacts.close(); + artifacts = undefined; + runtime.close(); + await owner.close(); + + owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + artifacts = await openInteractiveArtifactStoreForWrite(owner.lease); + await artifacts.recover(); + runtime = createSqliteRuntimeStore(runtimePath); + const recoveredEvents = await runtime.readRuntimeEvents(sessionId, runId); + backend = await createHostAiSdkBackend( + backendCreationFixture({ + abortSignal: new AbortController().signal, + resolveExecutionConnection: async () => + readyExecutionConnection(provider.baseUrl, { vision: true }), + readPricing: async () => ({ revision: 0, overrides: [] }), + artifacts, + }), + ); + for await (const _event of backend.send({ + invocationId: 'projection-image-replay-invocation', + runId: 'projection-image-replay-run', + turnId: 'projection-image-replay-turn', + text: 'Continue after restart.', + context: [], + runtimeContext: recoveredEvents, + })) { + // Drain the replay request built from the reopened authorities. + } + const streamRequests = provider.requests.filter((request) => request.body.stream === true); + assert.equal(streamRequests.length, 3); + assertProjectedImage(streamRequests[2]?.body); + } finally { + await backend?.dispose(); + artifacts?.close(); + runtime.close(); + await owner?.close(); + await provider.close(); + await rm(base, { recursive: true, force: true }); + } +}); + test('provider dispatch fails closed when the Run Composition commit fails', async () => { const provider = await startProvider(); let commits = 0; @@ -3558,6 +3698,7 @@ function backendCreationFixture(input: { recordModelCallAttempt?: BackendFactoryContext['recordModelCallAttempt']; createFetchTransport?: HostAiSdkBackendInput['createFetchTransport']; createRunComposer?: HostAiSdkBackendInput['createRunComposer']; + artifacts?: HostAiSdkBackendInput['artifacts']; }): HostAiSdkBackendInput { const runtimePolicy = input.runtimePolicy ?? @@ -3631,7 +3772,7 @@ function backendCreationFixture(input: { runtimePolicy, ...(input.oauthCredentials ? { oauthCredentials: input.oauthCredentials } : {}), createRunComposer, - artifacts: {}, + artifacts: input.artifacts ?? {}, executionArtifacts: { recordToolArtifacts: async () => undefined, toolResultArchive: createToolResultArchiveCapability({ @@ -3667,6 +3808,7 @@ function readyExecutionConnection( customization: { readonly requestHeaders?: Readonly>; readonly requestBodyOverlay?: Readonly>; + readonly vision?: boolean; } = {}, ) { return { @@ -3682,7 +3824,11 @@ function readyExecutionConnection( models: [ { id: MODEL_ID, - capabilities: { chat: true, functionCalling: true }, + capabilities: { + chat: true, + functionCalling: true, + ...(customization.vision !== undefined ? { vision: customization.vision } : {}), + }, contextWindow: 8_192, maxOutputTokens: 1_024, }, @@ -3959,6 +4105,7 @@ type ProviderFlow = readonly groupId: string; readonly toolName: string; } + | { readonly kind: 'projection_image'; readonly toolName: string } | { readonly kind: 'child_agent' } | { readonly kind: 'implementation_child_agent'; @@ -3972,6 +4119,7 @@ async function startProvider(): Promise<{ readonly requests: ProviderRequest[]; configureManagedBashFlow(sandboxPaths?: ManagedSandboxPaths): void; configureClientCapability(input: { groupId: string; toolName: string }): void; + configureProjectionImageFlow(toolName: string): void; configureChildAgentFlow(): void; configureImplementationChildAgentFlow(): void; configureAgentGraphFlow(): void; @@ -4001,6 +4149,10 @@ async function startProvider(): Promise<{ if (flow.kind !== 'default') throw new Error('Provider flow is already configured'); flow = { kind: 'client_capability', ...input }; }, + configureProjectionImageFlow: (toolName) => { + if (flow.kind !== 'default') throw new Error('Provider flow is already configured'); + flow = { kind: 'projection_image', toolName }; + }, configureChildAgentFlow: () => { if (flow.kind !== 'default') throw new Error('Provider flow is already configured'); flow = { kind: 'child_agent' }; @@ -4077,6 +4229,15 @@ async function handleProviderRequest( return; } const streamRequestIndex = requests.filter((candidate) => candidate.body.stream === true).length; + if (flow.kind === 'projection_image' && streamRequestIndex === 1) { + assert.ok(toolNames(body).includes(flow.toolName)); + respondProviderToolCall(response, streamRequestIndex, flow.toolName, {}); + return; + } + if (flow.kind === 'projection_image') { + respondProviderText(response, RESPONSE_TEXT); + return; + } if (flow.kind === 'managed_bash' && streamRequestIndex === 1) { assert.ok(toolNames(body).includes('Bash')); respondProviderToolCall(response, streamRequestIndex, 'Bash', { diff --git a/packages/storage/src/__tests__/artifact-attachments.test.ts b/packages/storage/src/__tests__/artifact-attachments.test.ts index 3f9bba3937..9f0feacd61 100644 --- a/packages/storage/src/__tests__/artifact-attachments.test.ts +++ b/packages/storage/src/__tests__/artifact-attachments.test.ts @@ -257,10 +257,12 @@ describe('artifact attachment authority', () => { bytes[0] = 0; input.name = 'mutated after prepare'; await Promise.all([plan.persist(), plan.persist()]); + const published = await store.list('session-1'); assert.deepEqual( - (await store.list('session-1')).map((artifact) => artifact.id), + published.map((artifact) => artifact.id), [plan.ref.relativePath], ); + assert.equal(published[0]?.name, 'Tool Result image'); assert.deepEqual(await store.readBinary(plan.ref.relativePath), { ok: true, base64: Buffer.from(png).toString('base64'), From d5aee93afd95fc2d78d4e02c9193047bdf0c68ea Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 14:15:12 +0800 Subject: [PATCH 23/30] refactor(runtime): preserve projection content fidelity Generated-by: Codex --- .../__tests__/durable-tool-result-projection.test.ts | 6 +++--- packages/runtime/src/durable-tool-result-projection.ts | 10 ++++------ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts b/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts index 08329d29a2..6b5003b6eb 100644 --- a/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts +++ b/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts @@ -30,7 +30,7 @@ import { } from '../durable-tool-result-projection.js'; describe('durable Tool Result projection codec', () => { - it('redacts text and JSON leaves before they become durable model content', () => { + it('preserves arbitrary text and JSON content faithfully', () => { assert.deepEqual( encodeDurableToolResultOutput( { type: 'text', value: 'Authorization: Bearer sk-live-secret-token-value' }, @@ -39,7 +39,7 @@ describe('durable Tool Result projection codec', () => { { version: 1, kind: 'text', - text: 'Authorization: Bearer [redacted]', + text: 'Authorization: Bearer sk-live-secret-token-value', }, ); assert.deepEqual( @@ -53,7 +53,7 @@ describe('durable Tool Result projection codec', () => { { version: 1, kind: 'json', - value: { password: '[redacted]', keep: 'visible' }, + value: { password: 'correct-horse-battery-staple', keep: 'visible' }, }, ); }); diff --git a/packages/runtime/src/durable-tool-result-projection.ts b/packages/runtime/src/durable-tool-result-projection.ts index 3ca67d3ae9..0bb3dd4b0e 100644 --- a/packages/runtime/src/durable-tool-result-projection.ts +++ b/packages/runtime/src/durable-tool-result-projection.ts @@ -36,7 +36,6 @@ import { } from '@maka/core/artifacts'; import { isCanonicalStorageRef } from '@maka/core/events'; import type { ToolResultContent } from '@maka/core/events'; -import { redactSecrets } from '@maka/core/redaction'; import type { RuntimeEventFunctionResponseContent } from '@maka/core/runtime-event'; import { decodeCanonicalShellToolResultContent } from '@maka/core/shell-run-result'; import { markPersisted } from '@maka/core/persisted-value'; @@ -277,7 +276,7 @@ function encodeOutput(output: ToolResultOutput, sessionId: string): DurableToolR return { version: DURABLE_TOOL_RESULT_PROJECTION_VERSION, kind: 'text', - text: redactSecrets(output.value), + text: output.value, ...(output.type === 'error-text' ? { isError: true as const } : {}), }; case 'json': @@ -292,7 +291,7 @@ function encodeOutput(output: ToolResultOutput, sessionId: string): DurableToolR return { version: DURABLE_TOOL_RESULT_PROJECTION_VERSION, kind: 'execution_denied', - ...(output.reason !== undefined ? { reason: redactSecrets(output.reason) } : {}), + ...(output.reason !== undefined ? { reason: output.reason } : {}), }; case 'content': { return prepareContentProjection(output, sessionId).projection; @@ -315,7 +314,7 @@ function prepareContentProjection( const artifacts: DurableProjectionArtifactPlan[] = []; for (const part of output.value) { if (part.type === 'text') { - parts.push({ kind: 'text', text: redactSecrets(part.text) }); + parts.push({ kind: 'text', text: part.text }); continue; } if (part.type !== 'file') continue; @@ -421,8 +420,7 @@ function isLegacyPathImageResult(result: unknown, sessionId: string): boolean { function sanitizeJson(value: unknown): DurableProjectionJson { const state = { nodes: 0 }; - const strictJson = sanitizeJsonValue(value, state, 0); - return JSON.parse(redactSecrets(JSON.stringify(strictJson))) as DurableProjectionJson; + return sanitizeJsonValue(value, state, 0); } function sanitizeJsonValue( From ab590038fa4c3cc264c8193d05b0c3479dd1d2a6 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 14:17:22 +0800 Subject: [PATCH 24/30] fix(storage): protect projection artifacts Generated-by: Codex --- packages/core/src/artifacts.ts | 2 ++ .../src/server/artifact-coordinator.ts | 2 +- .../__tests__/artifact-attachments.test.ts | 36 +++++++++++++++---- packages/storage/src/artifact-attachments.ts | 2 +- 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/packages/core/src/artifacts.ts b/packages/core/src/artifacts.ts index 02acd38dad..6128b27c0f 100644 --- a/packages/core/src/artifacts.ts +++ b/packages/core/src/artifacts.ts @@ -88,6 +88,7 @@ export function resolveArtifactImagePreview( export const ARTIFACT_SOURCES = [ 'tool_result', + 'tool_result_projection', 'tool_result_archive', 'synthesis_cache_block', 'history_compact_block', @@ -154,6 +155,7 @@ export interface ArtifactRecord extends ArtifactDescriptor { const ARTIFACT_USER_DELETE_ALLOWED_BY_SOURCE = { tool_result: true, + tool_result_projection: false, tool_result_archive: false, synthesis_cache_block: true, history_compact_block: true, diff --git a/packages/runtime-host/src/server/artifact-coordinator.ts b/packages/runtime-host/src/server/artifact-coordinator.ts index 195d5fb40c..1473a988aa 100644 --- a/packages/runtime-host/src/server/artifact-coordinator.ts +++ b/packages/runtime-host/src/server/artifact-coordinator.ts @@ -51,7 +51,7 @@ import { ConnectionBoundChunkUploads } from './connection-bound-chunk-uploads.js const MAX_ACTIVE_ARTIFACT_UPLOADS = 16; const MAX_STAGED_ARTIFACT_UPLOAD_BYTES = 128 * 1024 * 1024; const ARTIFACT_UPLOAD_TTL_MS = 5 * 60 * 1000; -const SHARED_ARTIFACT_SOURCES = new Set(['user_upload', 'tool_result']); +const SHARED_ARTIFACT_SOURCES = new Set(['user_upload', 'tool_result', 'tool_result_projection']); interface ArtifactUploadMetadata { readonly attachmentKind: AttachmentRef['kind']; diff --git a/packages/storage/src/__tests__/artifact-attachments.test.ts b/packages/storage/src/__tests__/artifact-attachments.test.ts index 9f0feacd61..ddc63dd389 100644 --- a/packages/storage/src/__tests__/artifact-attachments.test.ts +++ b/packages/storage/src/__tests__/artifact-attachments.test.ts @@ -31,7 +31,10 @@ import { createReadImageSnapshotPlanner, createReadImageSnapshotter, } from '../artifact-attachments.js'; -import { createSqliteArtifactStore as createArtifactStore } from '../artifact-store.js'; +import { + createSqliteArtifactStoreWriteAuthority, + type ArtifactAuthorityStore, +} from '../artifact-store.js'; const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); @@ -241,6 +244,27 @@ describe('artifact attachment authority', () => { }); }); + test('keeps a durable projection image live when a user requests deletion', async () => { + await withStore(async (store) => { + const ref = await createReadImageSnapshotter(store)({ + sessionId: 'session-1', + turnId: 'turn-1', + name: 'Tool Result image', + bytes: png, + mimeType: 'image/png', + }); + + assert.deepEqual(await store.deleteUserArtifactInSession('session-1', ref.relativePath), { + kind: 'protected', + }); + assert.deepEqual(await store.readBinary(ref.relativePath), { + ok: true, + base64: Buffer.from(png).toString('base64'), + mimeType: 'image/png', + }); + }); + }); + test('planner derives the final ref without publishing before commit', async () => { await withStore(async (store) => { const bytes = png.slice(); @@ -280,15 +304,15 @@ function sessionContextRef(refId: string, sessionId = 'session-1'): StorageRef { return { kind: 'session_context', sessionId, refId }; } -async function withStore( - run: (store: ReturnType) => Promise, -): Promise { +async function withStore(run: (store: ArtifactAuthorityStore) => Promise): Promise { const root = await mkdtemp(join(tmpdir(), 'maka-artifact-attachment-')); - const store = createArtifactStore(root); + const authority = createSqliteArtifactStoreWriteAuthority(root); try { + await authority.recover(); + const { store } = authority; await run(store); } finally { - store.close?.(); + authority.close(); await rm(root, { recursive: true, force: true }); } } diff --git a/packages/storage/src/artifact-attachments.ts b/packages/storage/src/artifact-attachments.ts index 867b086d1c..d281fe8206 100644 --- a/packages/storage/src/artifact-attachments.ts +++ b/packages/storage/src/artifact-attachments.ts @@ -189,7 +189,7 @@ export function createReadImageSnapshotPlanner(artifactStore: Pick { if (artifact.id !== id) throw new Error('Artifact publication changed its planned id'); From eea414d242483abd0e4b7a543f06a84658d2add7 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 14:19:54 +0800 Subject: [PATCH 25/30] fix(runtime): admit tool calls before publication Generated-by: Codex --- .../tool-runtime-durable-boundary.test.ts | 12 +-- .../tool-runtime-sqlite-boundary.test.ts | 9 +- packages/runtime/src/tool-runtime.ts | 82 +++++++++++-------- 3 files changed, 58 insertions(+), 45 deletions(-) diff --git a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts index 2986928531..2657372398 100644 --- a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts @@ -70,7 +70,7 @@ describe('ToolRuntime durable boundary', () => { ); }); - it('does not invoke a tool when another local dispatcher already owns its operation', async () => { + it('publishes no call side effects when another dispatcher owns the operation', async () => { let implementationCalls = 0; const harness = makeHarness({ commitToolPrepared: async () => ({ created: false, runtimeEventSeq: 1 }), @@ -90,14 +90,8 @@ describe('ToolRuntime durable boundary', () => { ); assert.equal(implementationCalls, 0); - assert.deepEqual( - harness.events.map((event) => event.type), - ['tool_start'], - ); - assert.deepEqual( - harness.messages.map((message) => message.type), - ['tool_call'], - ); + assert.deepEqual(harness.events, []); + assert.deepEqual(harness.messages, []); }); it('refuses durable tool execution when the turn carries no run id', async () => { diff --git a/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts index cd9b785669..e378e8bd86 100644 --- a/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts @@ -27,7 +27,7 @@ import { describe, it } from 'node:test'; import { createGenesisExecutionBoundary } from '@maka/core/sandbox-boundary'; import { type LlmConnection } from '@maka/core/llm-connections'; import { type SessionEvent } from '@maka/core/events'; -import { type SessionHeader } from '@maka/core/session'; +import { type SessionHeader, type StoredMessage } from '@maka/core/session'; import type { McpToolBinding } from '@maka/core/mcp'; import { createSqliteRuntimeStore, @@ -531,12 +531,15 @@ describe('ToolRuntime with real SQLite boundary', () => { try { let implementationCalls = 0; let artifactWrites = 0; + const appendedMessages: StoredMessage[] = []; const runtime = createTestToolRuntime({ sessionId: 'session-1', header: header(), connection: connection(), modelId: 'model-1', - appendMessage: async () => {}, + appendMessage: async (message) => { + appendedMessages.push(message); + }, newId: nextId(), now: nextNow(), getPermissionPauseTarget: () => null, @@ -601,6 +604,8 @@ describe('ToolRuntime with real SQLite boundary', () => { assert.equal(implementationCalls, 1); assert.equal(artifactWrites, 1); assert.equal(published.filter((event) => event.type === 'tool_result').length, 0); + assert.equal(published.filter((event) => event.type === 'tool_start').length, 1); + assert.equal(appendedMessages.filter((message) => message.type === 'tool_call').length, 1); const events = await store.readRuntimeEvents('session-1', 'run-1'); assert.deepEqual( events.map((event) => event.content?.kind), diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index b1bfa4c8e8..53ad84477d 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -1153,20 +1153,52 @@ export class ToolRuntime { ...(tool.displayName ? { displayName: tool.displayName } : {}), ...(stepId !== undefined ? { stepId } : {}), }; - let pushedCallEvent: ToolStartEvent | undefined; - const pushCallEvent = (lane: 'dispatch' | 'preflight'): ToolStartEvent => { - // Idempotent by construction: one call, one call event, whichever lane - // asks for it first. A second ask cannot mint a second id. - if (pushedCallEvent) return pushedCallEvent; + let callEvent: ToolStartEvent | undefined; + const buildCallEvent = (lane: 'dispatch' | 'preflight'): ToolStartEvent => { + if (callEvent) return callEvent; const operationId = lane === 'dispatch' ? dispatchOperationId : undefined; - const event: ToolStartEvent = { + callEvent = { ...callEventFacts, id: operationId ? `${operationId}_call` : this.input.newId(), ...(operationId ? { operationId } : {}), }; + return callEvent; + }; + let callEventPublished = false; + const publishCallEvent = (event: ToolStartEvent): void => { + if (callEventPublished) return; queue.push(event); - pushedCallEvent = event; - return event; + callEventPublished = true; + }; + const callMsg: ToolCallMessage = { + type: 'tool_call', + id: toolUseId, + turnId, + ts: now, + toolName: tool.name, + ...activityIdentity, + ...(tool.activityKind ? { activityKind: tool.activityKind } : {}), + ...(tool.displayName ? { displayName: tool.displayName } : {}), + args: structuredClone(persistedArgs), + ...(ctx.providerOptions !== undefined + ? { providerOptions: structuredClone(ctx.providerOptions) } + : {}), + // Persist the same step id the tool_start event carries so the UI + // timeline and post-restart backfill can pair this call with its step. + ...(stepId !== undefined ? { stepId } : {}), + }; + let callMessageAppended = false; + const appendCallMessage = async (): Promise => { + if (callMessageAppended) return; + await this.input.appendMessage(callMsg); + callMessageAppended = true; + }; + const emitToolStartedTrace = (): void => { + trace?.emit('tool', 'tool_started', 'Tool execution started', { + toolUseId, + toolName: tool.name, + ...(tool.categoryHint !== undefined ? { categoryHint: tool.categoryHint } : {}), + }); }; /** * One pre-dispatch refusal: the call fact on the generic lane, then the @@ -1177,7 +1209,9 @@ export class ToolRuntime { text: string, sandboxFailure?: Extract['sandboxFailure'], ): Promise => { - pushCallEvent('preflight'); + await appendCallMessage(); + publishCallEvent(buildCallEvent('preflight')); + emitToolStartedTrace(); await this.writeSyntheticToolResult( toolUseId, turnId, @@ -1190,29 +1224,6 @@ export class ToolRuntime { activityIdentity, ); }; - const callMsg: ToolCallMessage = { - type: 'tool_call', - id: toolUseId, - turnId, - ts: now, - toolName: tool.name, - ...activityIdentity, - ...(tool.activityKind ? { activityKind: tool.activityKind } : {}), - ...(tool.displayName ? { displayName: tool.displayName } : {}), - args: structuredClone(persistedArgs), - ...(ctx.providerOptions !== undefined - ? { providerOptions: structuredClone(ctx.providerOptions) } - : {}), - // Persist the same step id the tool_start event carries so the UI - // timeline and post-restart backfill can pair this call with its step. - ...(stepId !== undefined ? { stepId } : {}), - }; - await this.input.appendMessage(callMsg); - trace?.emit('tool', 'tool_started', 'Tool execution started', { - toolUseId, - toolName: tool.name, - ...(tool.categoryHint !== undefined ? { categoryHint: tool.categoryHint } : {}), - }); if (admissionFailure) { const boundaryKind = boundaryAuthorityAttempt ? ('invalid' as const) : undefined; if (boundaryKind) { @@ -1448,7 +1459,7 @@ export class ToolRuntime { try { durableAttempt = await this.prepareDurableToolAttempt({ tool, - startEvent: pushCallEvent('dispatch'), + startEvent: buildCallEvent('dispatch'), persistedArgs, modelFacingArgs, abortSignal: ctx.abortSignal, @@ -1463,6 +1474,9 @@ export class ToolRuntime { await disposeManagedMutationAdmission(managedMutationAdmission); throw error; } + await appendCallMessage(); + publishCallEvent(buildCallEvent('dispatch')); + emitToolStartedTrace(); if (durableAttempt) { this.durableToolAttempts.set(durableAttemptKey(turnId, toolUseId), durableAttempt); } @@ -1511,7 +1525,7 @@ export class ToolRuntime { toolCallId: toolUseId, // The id the call event actually carries, not the candidate: by here // `prepareDurableToolAttempt` has pushed it on the dispatch lane. - ...(pushedCallEvent?.operationId ? { operationId: pushedCallEvent.operationId } : {}), + ...(callEvent?.operationId ? { operationId: callEvent.operationId } : {}), abortSignal: ctx.abortSignal, emitOutput: output.emit, emitProgress: (current, total) => { From 762086a16f591583a96f69fcf327ee043d0a976b Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 14:21:07 +0800 Subject: [PATCH 26/30] fix(runtime): bound projection copies before allocation Generated-by: Codex --- .../durable-tool-result-projection.test.ts | 32 +++++++++++++++++++ .../src/durable-tool-result-projection.ts | 6 ++++ 2 files changed, 38 insertions(+) diff --git a/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts b/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts index 6b5003b6eb..836797f49d 100644 --- a/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts +++ b/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts @@ -19,6 +19,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; +import { MAX_READ_IMAGE_BYTES } from '@maka/core/attachments'; import { DURABLE_TOOL_RESULT_PROJECTION_MAX_BYTES } from '@maka/core/durable-tool-result-projection'; import { serializedByteLength } from '@maka/core/serialized-byte-length'; @@ -151,6 +152,37 @@ describe('durable Tool Result projection codec', () => { assert.equal(writes, 0); }); + it('rejects an oversized ArrayBuffer before copying its bytes', async () => { + class ObservableArrayBuffer extends ArrayBuffer { + copies = 0; + + override slice(begin?: number, end?: number): ArrayBuffer { + this.copies += 1; + return super.slice(begin, end); + } + } + const data = new ObservableArrayBuffer(MAX_READ_IMAGE_BYTES + 1); + const projection = await encodeDurableToolResultOutputWithArtifacts( + { + type: 'content', + value: [ + { + type: 'file', + data: { type: 'data', data }, + mediaType: 'image/png', + }, + ], + }, + 'session-1', + () => { + throw new Error('oversized bytes must not reach artifact planning'); + }, + ); + + assert.equal(projection.kind, 'failure'); + assert.equal(data.copies, 0); + }); + it('validates the complete projection before persisting any artifact', async () => { let writes = 0; const projection = await encodeDurableToolResultOutputWithArtifacts( diff --git a/packages/runtime/src/durable-tool-result-projection.ts b/packages/runtime/src/durable-tool-result-projection.ts index 0bb3dd4b0e..f81e4d5f82 100644 --- a/packages/runtime/src/durable-tool-result-projection.ts +++ b/packages/runtime/src/durable-tool-result-projection.ts @@ -388,8 +388,14 @@ function decodeBoundedImageData(data: unknown): Uint8Array { throw new Error('Inline image is not canonical base64'); bytes = decoded; } else if (data instanceof ArrayBuffer) { + if (data.byteLength > MAX_READ_IMAGE_BYTES) { + throw new Error('Inline image exceeds the artifact byte limit'); + } bytes = new Uint8Array(data.slice(0)); } else if (ArrayBuffer.isView(data)) { + if (data.byteLength > MAX_READ_IMAGE_BYTES) { + throw new Error('Inline image exceeds the artifact byte limit'); + } bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice(); } else { throw new Error('Inline image data is not representable'); From 3af5bfe232157b420a0e904672bd81e60fb99017 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 14:25:00 +0800 Subject: [PATCH 27/30] test(runtime-host): consolidate projection lifecycle coverage Generated-by: Codex --- .../execution-model-composition.test.ts | 36 ++++- .../src/__tests__/ai-sdk-backend.test.ts | 125 ------------------ .../src/__tests__/conversation-copy.test.ts | 113 ---------------- 3 files changed, 34 insertions(+), 240 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 abf9710cbd..698da29dab 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -667,6 +667,38 @@ test('Host reopens one projected image from its ArtifactStore authority', async const liveRequests = provider.requests.filter((request) => request.body.stream === true); assert.equal(liveRequests.length, 2); assertProjectedImage(liveRequests[1]?.body); + + const nextRunId = 'projection-image-next-run'; + const nextText = 'Continue in the same process.'; + const nextHead: RuntimeEvent = { + id: 'projection-image-next-head', + invocationId: nextRunId, + runId: nextRunId, + sessionId, + turnId: 'projection-image-next-turn', + ts: 2, + partial: false, + role: 'user', + author: 'user', + content: { kind: 'text', text: nextText }, + }; + await runtime.appendRuntimeEvent(sessionId, nextRunId, nextHead); + const nextTurnContext = [...(await runtime.readRuntimeEvents(sessionId, runId)), nextHead]; + for await (const _event of backend.send({ + invocationId: nextRunId, + runId: nextRunId, + turnId: nextHead.turnId, + headAnchorRuntimeEvent: nextHead, + text: nextText, + context: [], + runtimeContext: nextTurnContext, + })) { + // Drain the next Turn built from the same committed projection. + } + const nextTurnRequests = provider.requests.filter((request) => request.body.stream === true); + assert.equal(nextTurnRequests.length, 3); + assertProjectedImage(nextTurnRequests[2]?.body); + await backend.dispose(); backend = undefined; artifacts.close(); @@ -701,8 +733,8 @@ test('Host reopens one projected image from its ArtifactStore authority', async // Drain the replay request built from the reopened authorities. } const streamRequests = provider.requests.filter((request) => request.body.stream === true); - assert.equal(streamRequests.length, 3); - assertProjectedImage(streamRequests[2]?.body); + assert.equal(streamRequests.length, 4); + assertProjectedImage(streamRequests[3]?.body); } finally { await backend?.dispose(); artifacts?.close(); diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 63fac89ace..50a956ba7b 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -20,8 +20,6 @@ import assert from 'node:assert/strict'; import { Buffer } from 'node:buffer'; import { createHash } from 'node:crypto'; -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { describe, test } from 'node:test'; import type { ModelMessage, ModelStreamResult } from '../model-protocol.js'; @@ -36,7 +34,6 @@ import { createManagedExecutionBoundary } from '@maka/core/sandbox-boundary'; import type { SessionHeader } from '@maka/core/session'; import type { StorageRef } from '@maka/core/events'; import { encodeCanonicalRuntimeEvent } from '@maka/core/canonical-runtime-event'; -import { createSqliteRuntimeStore } from '@maka/storage/sqlite-runtime-store'; import type { SessionEvent } from '@maka/core/events'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { @@ -95,7 +92,6 @@ import { buildLlmHistorySummarizer } from '../history-compact-summarizer.js'; import { createToolResultArchiveCapability } from '../tool-result-archive-capability.js'; import { createTestAiSdkBackend, - createTestToolRuntime, readExternalExecutionBoundary, testToolResultArchive, } from './execution-boundary-test-helpers.js'; @@ -3780,127 +3776,6 @@ describe('AiSdkBackend model history', () => { ); }); - test('replays a persisted image projection after reopening its RuntimeEvent store', async () => { - const root = await mkdtemp(join(tmpdir(), 'maka-durable-projection-restart-')); - const databasePath = join(root, 'runtime.sqlite'); - const pngBytes = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==', - 'base64', - ); - const artifacts = new Map(); - const source = createSqliteRuntimeStore(databasePath); - try { - const runtime = createTestToolRuntime({ - sessionId: 'session-1', - header: header(), - connection: connection(), - modelId: 'mock-model-id', - appendMessage: async () => {}, - newId: idGenerator(), - now: monotonicClock(), - getPermissionPauseTarget: () => null, - turnId: 'turn-prev', - runId: 'run-prev', - invocationId: 'invocation-prev', - runtimeCommitSink: source, - prepareDurableProjectionArtifact: ({ bytes, mediaType }) => { - assert.equal(mediaType, 'image/png'); - const accepted = bytes.slice(); - return { - ref: { - kind: 'session_file', - sessionId: 'session-1', - relativePath: 'artifact-1', - }, - persist: async () => { - artifacts.set('artifact-1', accepted); - }, - }; - }, - }); - await runtime.settleToolCall({ - tool: { - name: 'PrivateTool', - description: 'returns a private execution fact', - parameters: z.object({}), - recoveryMode: 'replay_safe', - impl: async () => ({ secretExecutionFact: 'raw-secret-must-not-replay' }), - toModelOutput: () => ({ - type: 'content', - value: [ - { - type: 'file', - data: { type: 'data', data: pngBytes.toString('base64') }, - mediaType: 'image/png', - }, - ], - }), - }, - turnId: 'turn-prev', - toolCallId: 'restart-tool-call', - input: {}, - abortSignal: new AbortController().signal, - eventSink: { - push: () => {}, - pushAndWaitUntilConsumed: async () => {}, - }, - }); - } finally { - source.close(); - } - - const reopened = createSqliteRuntimeStore(databasePath); - try { - const recoveredEvents = await reopened.readRuntimeEvents('session-1', 'run-prev'); - const model = completionModel(); - const backend = createTestAiSdkBackend({ - sessionId: 'session-1', - header: header(), - appendMessage: async () => {}, - connection: connection(), - apiKey: 'sk-test', - modelId: 'mock-model-id', - modelFactory: () => model, - tools: [], - supportsVision: true, - maxProviderImageRequestBytes: pngBytes.byteLength, - readAttachmentBytes: async (ref) => { - const bytes = ref.kind === 'session_file' ? artifacts.get(ref.relativePath) : undefined; - return bytes - ? { ok: true, bytes: bytes.slice() } - : { ok: false, reason: 'not_found' as const }; - }, - newId: idGenerator(), - now: monotonicClock(), - }); - - await drain( - backend.send({ - turnId: 'turn-current', - text: 'continue after restart', - context: [], - runtimeContext: recoveredEvents, - }), - ); - - const prompt = compactPrompt(model) as Array<{ role: string; content: any[] }>; - const result = prompt.find((message) => message.role === 'tool')?.content[0]?.output; - assert.ok( - result.value.some( - (part: any) => - part.type === 'file' && - part.mediaType === 'image/png' && - part.data.data === pngBytes.toString('base64'), - ), - ); - const wire = JSON.stringify(prompt); - assert.doesNotMatch(wire, /raw-secret-must-not-replay/u); - } finally { - reopened.close(); - await rm(root, { recursive: true, force: true }); - } - }); - test('sends a live image tool result to the next provider step', async () => { const pngBytes = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==', diff --git a/packages/runtime/src/__tests__/conversation-copy.test.ts b/packages/runtime/src/__tests__/conversation-copy.test.ts index 92087aa383..cf7ccbb3a4 100644 --- a/packages/runtime/src/__tests__/conversation-copy.test.ts +++ b/packages/runtime/src/__tests__/conversation-copy.test.ts @@ -1357,119 +1357,6 @@ test('conversation copy rewrites a complete tool recovery bundle atomically', as } }); -test('conversation copy remaps durable projection artifacts with the copied result', async () => { - const root = await mkdtemp(join(tmpdir(), 'maka-conversation-projection-copy-')); - const runStore = createSqliteAgentRunStore(root); - const runtimeEventStore = createSqliteRuntimeStore(join(root, 'runtime.sqlite')); - try { - await runStore.ready?.(); - await runStore.createRun( - agentRunHeader({ - runId: 'run-source', - invocationId: 'invocation-source', - turnId: 'turn-1', - cwd: root, - }), - ); - const sourceRef = { - kind: 'session_file' as const, - sessionId: 'session-source', - relativePath: 'artifact-source', - }; - const sourceEvents: RuntimeEvent[] = [ - runtimeEvent({ - id: 'event-user', - role: 'user', - author: 'user', - content: { kind: 'text', text: 'read the image' }, - }), - runtimeEvent({ - id: 'event-call', - ts: 2, - role: 'model', - author: 'agent', - content: { - kind: 'function_call', - id: 'provider-call-1', - name: 'Read', - args: { path: 'image.png' }, - }, - }), - runtimeEvent({ - id: 'event-result', - ts: 3, - role: 'tool', - author: 'tool', - content: { - kind: 'function_response', - id: 'provider-call-1', - name: 'Read', - result: { kind: 'image', mimeType: 'image/png', ref: sourceRef }, - modelProjection: { - version: 1, - kind: 'content', - parts: [{ kind: 'artifact', mediaType: 'image/png', ref: sourceRef }], - }, - }, - }), - runtimeEvent({ id: 'event-terminal', ts: 4, status: 'completed' }), - ]; - await runtimeEventStore.importConversationCopyRuntimeEvents('session-source', [ - { runId: 'run-source', events: sourceEvents }, - ]); - await runStore.appendEvent('session-source', 'run-source', { - type: 'run_completed', - id: 'completed-source', - runId: 'run-source', - sessionId: 'session-source', - turnId: 'turn-1', - ts: 4, - }); - const source = await new RuntimeReadModel({ runStore, runtimeEventStore }).getSessionView( - 'session-source', - ); - - await cloneConversationRuntimeLedger({ - plan: await prepareTestCopyPlan(source, source.messages, runStore, runtimeEventStore), - copiedMessages: source.messages, - referenceMap: { - mode: 'exact', - linkedChildren: { mode: 'reject' }, - sourceSessionId: 'session-source', - targetSessionId: 'session-target', - artifactIds: new Map([['artifact-source', 'artifact-target']]), - relativePaths: new Map(), - }, - runStore, - runtimeEventStore, - newId: () => crypto.randomUUID(), - }); - - const [targetRun] = await runStore.listSessionRuns('session-target'); - assert.ok(targetRun); - const targetResult = ( - await runtimeEventStore.readRuntimeEvents('session-target', targetRun.runId) - ).find((event) => event.content?.kind === 'function_response'); - assert.equal(targetResult?.content?.kind, 'function_response'); - if (targetResult?.content?.kind !== 'function_response') return; - const expectedRef = { - kind: 'session_file', - sessionId: 'session-target', - relativePath: 'artifact-target', - }; - assert.deepEqual( - targetResult.content.modelProjection?.kind === 'content' - ? targetResult.content.modelProjection.parts[0] - : undefined, - { kind: 'artifact', mediaType: 'image/png', ref: expectedRef }, - ); - } finally { - runtimeEventStore.close(); - runStore.close?.(); - await rm(root, { recursive: true, force: true }); - } -}); - test('conversation copy rewrites the parent operation id of a nested Code Mode call', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-conversation-copy-parent-op-')); const runStore = createSqliteAgentRunStore(root); From 2cd37a6783fb3101ad25257b0e65f7eabd93a56a Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 14:57:20 +0800 Subject: [PATCH 28/30] test(runtime): lock T1 failure side effects Generated-by: Codex --- .../__tests__/tool-runtime-durable-boundary.test.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts index 2657372398..93be75ff92 100644 --- a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts @@ -60,14 +60,8 @@ describe('ToolRuntime durable boundary', () => { ); assert.equal(implementationCalls, 0); - assert.equal( - harness.events.some((event) => event.type === 'tool_result'), - false, - ); - assert.equal( - harness.messages.some((message) => message.type === 'tool_result'), - false, - ); + assert.deepEqual(harness.events, []); + assert.deepEqual(harness.messages, []); }); it('publishes no call side effects when another dispatcher owns the operation', async () => { From 5a70eab9b876832285260fc698155b64032c2569 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 15:12:48 +0800 Subject: [PATCH 29/30] refactor(core): unify artifact source policy Generated-by: Codex --- .../tools/artifacts/artifact-visibility.ts | 22 +-------- packages/core/src/__tests__/artifacts.test.ts | 20 ++++++++ packages/core/src/artifacts.ts | 48 ++++++++++++------- .../src/server/artifact-coordinator.ts | 8 +--- 4 files changed, 55 insertions(+), 43 deletions(-) diff --git a/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-visibility.ts b/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-visibility.ts index cc35bb97c5..cd7bb4c89f 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-visibility.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-visibility.ts @@ -17,28 +17,10 @@ * under the License. */ -import type { ArtifactDescriptor, ArtifactSource } from '@maka/core/artifacts'; - -const USER_VISIBLE_ARTIFACT_SOURCES = { - tool_result: false, - tool_result_archive: false, - synthesis_cache_block: false, - history_compact_block: false, - history_compact_source: false, - provider_request_capture: false, - session_effect: false, - subagent_writeback: true, - deep_research: true, - user_upload: false, - export: true, - snapshot: true, - fixture: true, -} satisfies Record; +import { isArtifactUserVisible, type ArtifactDescriptor } from '@maka/core/artifacts'; export function filterUserVisibleArtifacts( records: readonly ArtifactDescriptor[], ): ArtifactDescriptor[] { - return records.filter( - (record) => record.source === undefined || USER_VISIBLE_ARTIFACT_SOURCES[record.source], - ); + return records.filter(isArtifactUserVisible); } diff --git a/packages/core/src/__tests__/artifacts.test.ts b/packages/core/src/__tests__/artifacts.test.ts index 0fc3499a5f..53f5d4b2fb 100644 --- a/packages/core/src/__tests__/artifacts.test.ts +++ b/packages/core/src/__tests__/artifacts.test.ts @@ -23,6 +23,8 @@ import { ARTIFACT_ENTITY_ID_MAX_CHARS, ARTIFACT_TURN_KEY_MAX_CHARS, canUserDeleteArtifact, + isArtifactSharedSessionReadable, + isArtifactUserVisible, isArtifactTurnKey, isCanonicalArtifactEntityId, } from '../artifacts.js'; @@ -61,3 +63,21 @@ describe('Artifact user-delete policy', () => { assert.equal(canUserDeleteArtifact({ source: undefined }), true); }); }); + +describe('Artifact source policy', () => { + test('keeps projection artifacts internal, durable, and readable in shared sessions', () => { + const projection = { source: 'tool_result_projection' as const }; + + assert.equal(canUserDeleteArtifact(projection), false); + assert.equal(isArtifactUserVisible(projection), false); + assert.equal(isArtifactSharedSessionReadable(projection), true); + }); + + test('preserves unattributed artifact defaults', () => { + const unattributed = { source: undefined }; + + assert.equal(canUserDeleteArtifact(unattributed), true); + assert.equal(isArtifactUserVisible(unattributed), true); + assert.equal(isArtifactSharedSessionReadable(unattributed), false); + }); +}); diff --git a/packages/core/src/artifacts.ts b/packages/core/src/artifacts.ts index 6128b27c0f..0e203c9f92 100644 --- a/packages/core/src/artifacts.ts +++ b/packages/core/src/artifacts.ts @@ -153,25 +153,39 @@ export interface ArtifactRecord extends ArtifactDescriptor { deepResearchRole?: import('./deep-research-run.js').DeepResearchArtifactRole; } -const ARTIFACT_USER_DELETE_ALLOWED_BY_SOURCE = { - tool_result: true, - tool_result_projection: false, - tool_result_archive: false, - synthesis_cache_block: true, - history_compact_block: true, - history_compact_source: true, - provider_request_capture: true, - subagent_writeback: false, - deep_research: false, - user_upload: true, - export: true, - snapshot: true, - session_effect: false, - fixture: true, -} as const satisfies Record; +interface ArtifactSourcePolicy { + readonly userDeletable: boolean; + readonly userVisible: boolean; + readonly sharedReadable: boolean; +} + +const ARTIFACT_SOURCE_POLICIES = { + tool_result: { userDeletable: true, userVisible: false, sharedReadable: true }, + tool_result_projection: { userDeletable: false, userVisible: false, sharedReadable: true }, + tool_result_archive: { userDeletable: false, userVisible: false, sharedReadable: false }, + synthesis_cache_block: { userDeletable: true, userVisible: false, sharedReadable: false }, + history_compact_block: { userDeletable: true, userVisible: false, sharedReadable: false }, + history_compact_source: { userDeletable: true, userVisible: false, sharedReadable: false }, + provider_request_capture: { userDeletable: true, userVisible: false, sharedReadable: false }, + subagent_writeback: { userDeletable: false, userVisible: true, sharedReadable: false }, + deep_research: { userDeletable: false, userVisible: true, sharedReadable: false }, + user_upload: { userDeletable: true, userVisible: false, sharedReadable: true }, + export: { userDeletable: true, userVisible: true, sharedReadable: false }, + snapshot: { userDeletable: true, userVisible: true, sharedReadable: false }, + session_effect: { userDeletable: false, userVisible: false, sharedReadable: false }, + fixture: { userDeletable: true, userVisible: true, sharedReadable: false }, +} as const satisfies Record; export function canUserDeleteArtifact(record: Pick): boolean { - return record.source === undefined || ARTIFACT_USER_DELETE_ALLOWED_BY_SOURCE[record.source]; + return record.source === undefined || ARTIFACT_SOURCE_POLICIES[record.source].userDeletable; +} + +export function isArtifactUserVisible(record: Pick): boolean { + return record.source === undefined || ARTIFACT_SOURCE_POLICIES[record.source].userVisible; +} + +export function isArtifactSharedSessionReadable(record: Pick): boolean { + return record.source !== undefined && ARTIFACT_SOURCE_POLICIES[record.source].sharedReadable; } export type ArtifactChangedReason = 'created' | 'deleted' | 'purged'; diff --git a/packages/runtime-host/src/server/artifact-coordinator.ts b/packages/runtime-host/src/server/artifact-coordinator.ts index 1473a988aa..3a278deb99 100644 --- a/packages/runtime-host/src/server/artifact-coordinator.ts +++ b/packages/runtime-host/src/server/artifact-coordinator.ts @@ -20,7 +20,7 @@ import { createHash } from 'node:crypto'; import { attachmentKindFromMimeType } from '@maka/core/attachments'; import type { AttachmentRef } from '@maka/core/events'; -import type { ArtifactRecord } from '@maka/core/artifacts'; +import { isArtifactSharedSessionReadable, type ArtifactRecord } from '@maka/core/artifacts'; import { authenticateInteractiveArtifactStoreWriter, sanitizeArtifactName, @@ -51,8 +51,6 @@ import { ConnectionBoundChunkUploads } from './connection-bound-chunk-uploads.js const MAX_ACTIVE_ARTIFACT_UPLOADS = 16; const MAX_STAGED_ARTIFACT_UPLOAD_BYTES = 128 * 1024 * 1024; const ARTIFACT_UPLOAD_TTL_MS = 5 * 60 * 1000; -const SHARED_ARTIFACT_SOURCES = new Set(['user_upload', 'tool_result', 'tool_result_projection']); - interface ArtifactUploadMetadata { readonly attachmentKind: AttachmentRef['kind']; readonly name: string; @@ -443,9 +441,7 @@ export class HostArtifactCoordinator { ); if (!grant) return; const entry = await this.#store.getInSession(input.sessionId, input.artifactId); - return entry.record?.status === 'live' && - entry.record.source !== undefined && - SHARED_ARTIFACT_SOURCES.has(entry.record.source) + return entry.record?.status === 'live' && isArtifactSharedSessionReadable(entry.record) ? grant.grantId : undefined; } From 619065706eaffb0d5ef2481058c88d12ac733fda Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 31 Aug 2026 16:49:35 +0800 Subject: [PATCH 30/30] fix(runtime): make tool result projectors total Generated-by: Codex --- .../tool-runtime-durable-boundary.test.ts | 29 +++++++++++++++++++ packages/runtime/src/tool-runtime.ts | 17 ++++++----- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts index 93be75ff92..c6f52e935f 100644 --- a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts @@ -262,6 +262,35 @@ describe('ToolRuntime durable boundary', () => { ); }); + it('commits fallback instead of awaiting an asynchronous projector', { + timeout: 1_000, + }, async () => { + const outcomes: ToolOutcomeCommit[] = []; + const harness = makeHarness({ + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async (input) => { + outcomes.push(input); + return { created: true, runtimeEventSeq: 2 }; + }, + }); + const invalidTool = tool(() => ({ private: 'completed execution fact' })); + invalidTool.toModelOutput = (() => + new Promise(() => undefined)) as unknown as NonNullable; + + await harness.execute(invalidTool); + + const response = outcomes[0]?.runtimeEvent.content; + assert.deepEqual( + response?.kind === 'function_response' ? response.modelProjection : undefined, + { + version: 1, + kind: 'failure', + reason: 'projection_failed', + message: 'The tool completed, but its model-visible result could not be projected safely.', + }, + ); + }); + it('persists inline image output as a Session artifact before committing T2', async () => { const order: string[] = []; const outcomes: ToolOutcomeCommit[] = []; diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index 53ad84477d..66d2797313 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -199,12 +199,12 @@ export interface MakaTool

{ * settlement instead of detaching, so late side effects cannot outlive `exec`. */ impl: (args: P, ctx: MakaToolContext) => Promise | R; - /** Optional provider-visible content mapping, used for screenshot image parts. */ + /** Optional synchronous provider-visible content mapping, used for screenshot image parts. */ toModelOutput?: (options: { toolCallId: string; input: unknown; output: unknown; - }) => ToolResultOutput | PromiseLike; + }) => ToolResultOutput; } export interface MakaToolContext { @@ -779,6 +779,12 @@ export class ToolRuntime { return encodeDefaultDurableToolResultOutput(result, this.input.sessionId); } const output = tool.toModelOutput({ toolCallId, input, output: result }); + // Projection is deliberately synchronous and total at the tool boundary. + // Fail closed for untyped/plugin implementations that violate the + // contract so a completed effect can never be stranded before T2. + if (isPromiseLike(output)) { + return DURABLE_TOOL_RESULT_PROJECTION_FAILURE; + } const encode = (resolved: ToolResultOutput) => encodeDurableToolResultOutputWithArtifacts( resolved, @@ -792,12 +798,7 @@ export class ToolRuntime { }) : undefined, ); - return isPromiseLike(output) - ? Promise.resolve(output).then( - (resolved) => encode(resolved), - () => DURABLE_TOOL_RESULT_PROJECTION_FAILURE, - ) - : encode(output); + return encode(output); } catch { return DURABLE_TOOL_RESULT_PROJECTION_FAILURE; }