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/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__/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/__tests__/durable-tool-result-projection.test.ts b/packages/core/src/__tests__/durable-tool-result-projection.test.ts new file mode 100644 index 0000000000..9726ccbaa9 --- /dev/null +++ b/packages/core/src/__tests__/durable-tool-result-projection.test.ts @@ -0,0 +1,160 @@ +/* + * 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, + 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', () => { + 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/, + ); + }); + + 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/__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/artifacts.ts b/packages/core/src/artifacts.ts index 02acd38dad..0e203c9f92 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', @@ -152,24 +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_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/core/src/durable-tool-result-projection.ts b/packages/core/src/durable-tool-result-projection.ts new file mode 100644 index 0000000000..3a5a397597 --- /dev/null +++ b/packages/core/src/durable-tool-result-projection.ts @@ -0,0 +1,189 @@ +/* + * 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, 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.'; + +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, { nodes: 0 }, 0) && + (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' && + 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, + 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' || + typeof value === 'boolean' || + (typeof value === 'number' && Number.isFinite(value)) + ) { + return true; + } + 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/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' && 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-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/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index e7a74b52a6..698da29dab 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,178 @@ 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); + + 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(); + 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, 4); + assertProjectedImage(streamRequests[3]?.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 +3730,7 @@ function backendCreationFixture(input: { recordModelCallAttempt?: BackendFactoryContext['recordModelCallAttempt']; createFetchTransport?: HostAiSdkBackendInput['createFetchTransport']; createRunComposer?: HostAiSdkBackendInput['createRunComposer']; + artifacts?: HostAiSdkBackendInput['artifacts']; }): HostAiSdkBackendInput { const runtimePolicy = input.runtimePolicy ?? @@ -3631,7 +3804,7 @@ function backendCreationFixture(input: { runtimePolicy, ...(input.oauthCredentials ? { oauthCredentials: input.oauthCredentials } : {}), createRunComposer, - artifacts: {}, + artifacts: input.artifacts ?? {}, executionArtifacts: { recordToolArtifacts: async () => undefined, toolResultArchive: createToolResultArchiveCapability({ @@ -3667,6 +3840,7 @@ function readyExecutionConnection( customization: { readonly requestHeaders?: Readonly>; readonly requestBodyOverlay?: Readonly>; + readonly vision?: boolean; } = {}, ) { return { @@ -3682,7 +3856,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 +4137,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 +4151,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 +4181,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 +4261,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/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-host/src/server/artifact-coordinator.ts b/packages/runtime-host/src/server/artifact-coordinator.ts index 195d5fb40c..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']); - 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; } 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..3408e5cf50 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, + createReadImageSnapshotPlanner, persistProviderRequestCaptureArtifact, type InteractiveArtifactStoreWriter, } from '@maka/storage/artifact-stores'; @@ -332,6 +333,7 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom ); } : undefined; + const planProjectionImage = createReadImageSnapshotPlanner(input.artifacts); try { return new HostAiSdkBackend( @@ -398,6 +400,14 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom ? { readImageSnapshotsUnavailable: true } : {}), }), + prepareDurableProjectionArtifact: ({ turnId, bytes, mediaType }) => + planProjectionImage({ + 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/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__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 8ac41d9d43..50a956ba7b 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -20,7 +20,7 @@ import assert from 'node:assert/strict'; import { Buffer } from 'node:buffer'; import { createHash } from 'node:crypto'; -import { resolve } from 'node:path'; +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'; @@ -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 () => { @@ -6907,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({ @@ -6925,19 +7003,18 @@ describe('AiSdkBackend error surfaces', () => { now: () => 1, }); - await turnScope(backend, 'turn-1').toolRuntime.writeSyntheticToolResult( - 'tool-1', - 'turn-1', - '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/__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__/durable-tool-result-projection.test.ts b/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts new file mode 100644 index 0000000000..836797f49d --- /dev/null +++ b/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts @@ -0,0 +1,315 @@ +/* + * 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 { 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'; + +import { + decodeEffectiveToolResultProjection, + encodeDefaultDurableToolResultOutput, + encodeDurableToolResultOutput, + encodeDurableToolResultOutputWithArtifacts, +} from '../durable-tool-result-projection.js'; + +describe('durable Tool Result projection codec', () => { + it('preserves arbitrary text and JSON content faithfully', () => { + assert.deepEqual( + encodeDurableToolResultOutput( + { type: 'text', value: 'Authorization: Bearer sk-live-secret-token-value' }, + 'session-1', + ), + { + version: 1, + kind: 'text', + text: 'Authorization: Bearer sk-live-secret-token-value', + }, + ); + assert.deepEqual( + encodeDurableToolResultOutput( + { + type: 'json', + value: { password: 'correct-horse-battery-staple', keep: 'visible' }, + }, + 'session-1', + ), + { + version: 1, + kind: 'json', + value: { password: 'correct-horse-battery-staple', 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 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', + artifactPlanner(() => { + writes += 1; + }), + ); + + assert.equal(projection.kind, 'failure'); + 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( + { + 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'); + assert.equal(writes, 0); + }); + + 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 }, + ); + }); +}); + +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 65900de151..a40de02125 100644 --- a/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts +++ b/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts @@ -31,12 +31,44 @@ 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 + ? { + prepareDurableProjectionArtifact: ({ bytes }: { bytes: Uint8Array }) => { + const relativePath = `artifact-${++nextArtifactId}`; + const accepted = bytes.slice(); + return { + ref: { + kind: 'session_file' as const, + sessionId: input.sessionId, + relativePath, + }, + persist: async () => { + artifacts.set(relativePath, accepted); + }, + }; + }, + 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__/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..884d8647ea 100644 --- a/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts +++ b/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts @@ -372,7 +372,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 +383,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/, ); 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/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 880a44922e..b1b9bb7e12 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,277 +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 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(); @@ -11737,727 +11465,6 @@ describe('SessionManager permission mode updates', () => { const [turn] = await store.listTurns(session.id); 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/__tests__/tool-runtime-durable-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts index e6a56f7c66..c6f52e935f 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, @@ -59,17 +60,11 @@ 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('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 }), @@ -89,14 +84,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 () => { @@ -194,6 +183,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 +195,162 @@ 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 fallback when projection fails', 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('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[] = []; + 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', + { + prepareDurableProjectionArtifact: (input) => { + assert.equal(input.turnId, 'turn-1'); + assert.equal(input.mediaType, 'image/png'); + assert.deepEqual([...input.bytes], [137, 80, 78, 71]); + return { + ref: artifactRef, + persist: async () => { + order.push('artifact'); + }, + }; + }, + }, + ); + 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 +1776,9 @@ function managedOutcomeEvent( isError: boolean, options: { durationMs?: number; + modelProjection?: NonNullable< + Extract['modelProjection'] + >; origin?: 'provider' | 'code_mode'; modelVisibility?: 'visible' | 'hidden'; toolCallId?: string; @@ -1653,6 +1805,7 @@ function managedOutcomeEvent( name: 'Write', result, ...(isError ? { isError: true } : {}), + modelProjection: options.modelProjection ?? managedModelProjection(result), }, refs: { operationId, @@ -1664,6 +1817,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-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 12a34a65c6..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,39 +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('uses the runtime model-output materializer for default tool results', async () => { - const result = { kind: 'image', ref: 'artifact-1' }; - const runtime = makeRuntime({ - materializeDefaultToolResultOutput: async ({ toolCallId, output }) => { - assert.equal(toolCallId, 'call-1'); - assert.equal(output, result); - return { type: 'text', value: 'materialized image' }; - }, - }); - - 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(settledProjection(events), { + version: 1, + kind: 'json', + value: result, }); - - 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: { @@ -391,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', @@ -400,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, }); }); @@ -527,10 +534,7 @@ describe('ToolRuntime settlement', () => { function makeRuntime( overrides: Partial< - Pick< - ToolRuntimeInput, - 'materializeDefaultToolResultOutput' | 'readExecutionBoundary' | 'spawnChildSession' | 'runId' - > + Pick > = {}, ): ToolRuntime { return createTestToolRuntime({ @@ -546,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 43a9c952f3..e378e8bd86 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'; @@ -26,9 +27,12 @@ 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 } from '@maka/storage/sqlite-runtime-store'; +import { + createSqliteRuntimeStore, + type SqliteRuntimeStoreFailpoint, +} from '@maka/storage/sqlite-runtime-store'; import { createSessionEventMapMemory, mapSessionEventToRuntimeEvent, @@ -471,6 +475,16 @@ 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' }, + }); 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); @@ -496,6 +513,113 @@ 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 runtimeEventInsertions = 0; + let failT2 = true; + const store = createSqliteRuntimeStore(join(root, 'runtime.sqlite'), { + failpoint: (point) => { + if ( + point === ('after_runtime_event_insert' satisfies SqliteRuntimeStoreFailpoint) && + failT2 && + ++runtimeEventInsertions === 2 + ) { + throw new Error(`sqlite runtime failpoint: ${point}`); + } + }, + }); + 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 (message) => { + appendedMessages.push(message); + }, + newId: nextId(), + now: nextNow(), + getPermissionPauseTarget: () => null, + runId: 'run-1', + invocationId: 'invocation-1', + runtimeCommitSink: store, + prepareDurableProjectionArtifact: () => { + return { + ref: { + kind: 'session_file', + sessionId: 'session-1', + relativePath: 'projection-artifact', + }, + persist: async () => { + artifactWrites += 1; + }, + }; + }, + }); + 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); + 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, 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), + ['function_call', undefined], + ); + const operationId = events[0]?.refs?.operationId; + assert.ok(operationId); + assert.equal((await store.readToolOperation(operationId))?.currentState, 'prepared'); + } 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')); @@ -545,10 +669,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..b51269200d 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 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. @@ -859,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 @@ -1106,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 ( @@ -1323,8 +1358,7 @@ 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), + prepareDurableProjectionArtifact: input.prepareDurableProjectionArtifact, spawnChildSession: input.spawnChildSession, listChildAgents: input.listChildAgents, readChildAgentOutput: input.readChildAgentOutput, @@ -1854,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) { @@ -1906,7 +1939,6 @@ export class AiSdkBackend implements AgentBackend { const currentTurnMessages = await this.materializeRuntimeReplayPlan( replayPlan, scope.imageBudget, - settledModelOutputs, projectionCheckpoint, ); return projectionCheckpoint @@ -2675,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) && @@ -3104,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()}`, @@ -3615,7 +3640,6 @@ export class AiSdkBackend implements AgentBackend { messages: await this.materializeRuntimeReplayPlan( plan, scope.imageBudget, - undefined, projectedHistoryCompactCheckpoint, ), gate: 'runtime_replay_text_only', @@ -3639,7 +3663,6 @@ export class AiSdkBackend implements AgentBackend { ? await this.materializeRuntimeReplayPlan( degradedPlan, scope.imageBudget, - undefined, projectedHistoryCompactCheckpoint, ) : await materializeReplayFallback(), @@ -3658,7 +3681,6 @@ export class AiSdkBackend implements AgentBackend { messages: await this.materializeRuntimeReplayPlan( plan, scope.imageBudget, - undefined, projectedHistoryCompactCheckpoint, ), gate: 'runtime_replay_provider_native', @@ -3730,7 +3752,6 @@ export class AiSdkBackend implements AgentBackend { private async materializeRuntimeReplayPlan( plan: RuntimeEventModelReplayPlan, budget: ProviderImageBudget, - settledModelOutputs?: ReadonlyMap, historyCompactCheckpoint?: HistoryCompactCheckpoint, ): Promise { type ToolCallItem = Extract; @@ -3854,14 +3875,18 @@ export class AiSdkBackend implements AgentBackend { result: ToolResultItem, toolName: string, ): Promise => { - const output = - settledModelOutputs?.get(result.toolCallId) ?? - (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; }; @@ -4053,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) ?? []; @@ -4413,6 +4440,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/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 new file mode 100644 index 0000000000..f81e4d5f82 --- /dev/null +++ b/packages/runtime/src/durable-tool-result-projection.ts @@ -0,0 +1,478 @@ +/* + * 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_JSON_DEPTH, + DURABLE_TOOL_RESULT_PROJECTION_MAX_JSON_NODES, + 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, + normalizeArtifactImagePreviewMime, +} from '@maka/core/artifacts'; +import { isCanonicalStorageRef } from '@maka/core/events'; +import type { ToolResultContent } from '@maka/core/events'; +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.]'; + +/** + * 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; + } +} + +interface DurableProjectionArtifactPlan { + ref: Extract; + persist(): Promise; +} + +type DurableProjectionArtifactPlanner = (input: { + bytes: Uint8Array; + mediaType: string; +}) => DurableProjectionArtifactPlan; + +export function encodeDurableToolResultOutputWithArtifacts( + output: ToolResultOutput, + sessionId: string, + planArtifact: DurableProjectionArtifactPlanner | undefined, +): DurableToolResultProjection | PromiseLike { + if (!planArtifact || output.type !== 'content' || !hasInlineImage(output)) { + return encodeDurableToolResultOutput(output, sessionId); + } + return (async () => { + try { + 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 prepared.projection; + } catch { + return DURABLE_TOOL_RESULT_PROJECTION_FAILURE; + } + })(); +} + +export function encodeDefaultDurableToolResultOutput( + result: unknown, + sessionId: string, +): DurableToolResultProjection { + 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, 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 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 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, + ), + }; +} + +type EffectiveToolResultProjection = + | { + kind: 'projection'; + 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', + projection: decodeDurableToolResultProjection(content.modelProjection), + legacyOutput: content.result, + }; + } catch { + return { + kind: 'projection', + 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', + 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: 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: output.reason } : {}), + }; + case 'content': { + 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: 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( + 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) { + 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'); + } + 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 }; + return sanitizeJsonValue(value, state, 0); +} + +function sanitizeJsonValue( + value: unknown, + state: { nodes: number }, + depth: number, +): DurableProjectionJson { + state.nodes += 1; + 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; + 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' + ); +} diff --git a/packages/runtime/src/model-history.ts b/packages/runtime/src/model-history.ts index 53332e4343..1a74cde6c6 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,7 @@ export type RuntimeEventModelReplayItem = toolName: string; output: unknown; isError: boolean; + modelProjection?: DurableToolResultProjection; providerExecuted?: boolean; eventId: string; ts: number; @@ -610,47 +606,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 +653,11 @@ export function buildRuntimeEventModelReplayPlan( toolCallId: event.content.id, toolName: event.content.name, output: normalizedResult, + ...(effective.kind === 'projection' + ? { + modelProjection: effective.projection, + } + : {}), isError: event.content.isError === true, ...(event.content.providerExecuted !== undefined ? { providerExecuted: event.content.providerExecuted } @@ -749,12 +724,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/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 [ diff --git a/packages/runtime/src/session-event-runtime-mapper.ts b/packages/runtime/src/session-event-runtime-mapper.ts index 3044d110f9..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,17 +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) } - : {}), + ...content, + ...(modelProjection ? { modelProjection } : {}), }, refs: { toolCallId: event.toolUseId, diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 96591f9d73..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, @@ -3875,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; @@ -3900,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, @@ -4471,54 +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; - return prepareConversationRuntimeLedgerCopy({ - sourceSessionId, - sourceEvents: sourceView.events, - copiedMessages, - runStore: this.deps.runStore, - runtimeEventStore: this.deps.runtimeEventStore, - }); - } - - 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 diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index a5d612072a..66d2797313 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -82,8 +82,18 @@ 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 { + 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, @@ -137,11 +147,6 @@ export interface DurableSessionEventSink { } export interface ToolSettlement { - result: unknown; - modelOutput: ToolResultOutput; -} - -export interface RawToolSettlement { result: unknown; providerError?: string; } @@ -194,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 { @@ -349,10 +354,14 @@ export interface ToolRuntimeInput { runId?: string; orchestrationMode?: OrchestrationMode; invocationId?: string; - materializeDefaultToolResultOutput?: (options: { - toolCallId: string; - output: unknown; - }) => ToolResultOutput | PromiseLike; + prepareDurableProjectionArtifact?: (input: { + turnId: string; + bytes: Uint8Array; + mediaType: string; + }) => { + ref: Extract; + persist(): Promise; + }; spawnChildSession?: (input: { parentRunId: string; parentTurnId: string; @@ -404,6 +413,7 @@ interface RuntimeManagedMutationOperationValue { readonly content: ToolResultContent; readonly isError: boolean; readonly durationMs: number; + readonly modelProjection: DurableToolResultProjection; }; } @@ -416,6 +426,7 @@ export interface RuntimeManagedMutationOperationProof { readonly content: ToolResultContent; readonly isError: boolean; readonly durationMs: number; + readonly modelProjection: DurableToolResultProjection; } export type RuntimeManagedMutationSettlement = @@ -446,12 +457,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 }; } @@ -709,38 +722,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 = 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({ - toolCallId: call.toolCallId, - input: call.input, - output: settlement.result, - }) - : 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) }; - 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 @@ -753,7 +734,7 @@ export class ToolRuntime { return settlement; } - private async performToolSettlement(call: ResolvedMakaToolCall): Promise { + private async performToolSettlement(call: ResolvedMakaToolCall): Promise { const result = await this.executeTool( call.tool, call.turnId, @@ -774,6 +755,55 @@ export class ToolRuntime { return { result, ...(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 }); + // 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, + this.input.sessionId, + this.input.prepareDurableProjectionArtifact + ? ({ bytes, mediaType }) => + this.input.prepareDurableProjectionArtifact!({ + turnId, + bytes, + mediaType, + }) + : undefined, + ); + return encode(output); + } catch { + return DURABLE_TOOL_RESULT_PROJECTION_FAILURE; + } + } + /** * Install the per-step tool-availability gating used at the execute boundary. * The backend recomputes the active snapshot before each step; the guard in @@ -891,9 +921,10 @@ export class ToolRuntime { this.lastFailedToolCallBoundaryDetails = boundaryDetails; } - async writeSyntheticToolResult( + private async writeSyntheticToolResult( toolUseId: string, turnId: string, + toolName: string, text: string, queue: DurableSessionEventSink, sandboxDenial?: SandboxDenialSignal, @@ -924,7 +955,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,6 +987,7 @@ export class ToolRuntime { ...(durableOutcome ? { operationId: durableOutcome.operationId } : {}), isError: true, content, + modelProjection, ...activityIdentity, } satisfies ToolResultEvent); } @@ -1111,20 +1154,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 @@ -1135,10 +1210,13 @@ export class ToolRuntime { text: string, sandboxFailure?: Extract['sandboxFailure'], ): Promise => { - pushCallEvent('preflight'); + await appendCallMessage(); + publishCallEvent(buildCallEvent('preflight')); + emitToolStartedTrace(); await this.writeSyntheticToolResult( toolUseId, turnId, + tool.name, text, queue, undefined, @@ -1147,29 +1225,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) { @@ -1405,7 +1460,7 @@ export class ToolRuntime { try { durableAttempt = await this.prepareDurableToolAttempt({ tool, - startEvent: pushCallEvent('dispatch'), + startEvent: buildCallEvent('dispatch'), persistedArgs, modelFacingArgs, abortSignal: ctx.abortSignal, @@ -1420,6 +1475,9 @@ export class ToolRuntime { await disposeManagedMutationAdmission(managedMutationAdmission); throw error; } + await appendCallMessage(); + publishCallEvent(buildCallEvent('dispatch')); + emitToolStartedTrace(); if (durableAttempt) { this.durableToolAttempts.set(durableAttemptKey(turnId, toolUseId), durableAttempt); } @@ -1468,7 +1526,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) => { @@ -1549,10 +1607,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 +1654,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 +1726,7 @@ export class ToolRuntime { } const { result, outcome } = settledExecution.value; output.flush(); - const { content, durationMs } = outcome; + const { content, durationMs, modelProjection } = outcome; // 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 +1743,14 @@ export class ToolRuntime { settledExecution.durableOutcome, content, outcome.isError, + modelProjection, durationMs, ); } else { durableOutcome = await durableAttempt?.commitOutcome( content, outcome.isError, + modelProjection, durationMs, ); } @@ -1732,6 +1796,7 @@ export class ToolRuntime { ...(durableOutcome ? { operationId: durableOutcome.operationId } : {}), isError: toolResultStatus !== 'success', content, + modelProjection, durationMs, ...activityIdentity, } satisfies ToolResultEvent); @@ -1846,9 +1911,19 @@ 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; const durableOutcome = await durableAttempt?.commitOutcome( terminalFailure.content, true, + modelProjection, durationMs, ); const resultMsg: ToolResultMessage = { @@ -1872,6 +1947,7 @@ export class ToolRuntime { ...(durableOutcome ? { operationId: durableOutcome.operationId } : {}), isError: true, content: terminalFailure.content, + modelProjection, durationMs, ...activityIdentity, } satisfies ToolResultEvent); @@ -1902,7 +1978,7 @@ export class ToolRuntime { errorClass, ...(sandboxError ? { sandbox: sandboxError } : {}), }); - return this.errorReturn(terminalFailure.message); + return terminalResult; } const msg = err instanceof ToolResultLimitError @@ -1915,6 +1991,7 @@ export class ToolRuntime { await this.writeSyntheticToolResult( toolUseId, turnId, + tool.name, msg, queue, sandboxDenialSignalFromError(err), @@ -2049,6 +2126,7 @@ export class ToolRuntime { actions: { toolDispatch: { protocol: TOOL_BOUNDARY_PROTOCOL_V1, + resultProjectionVersion: 1, operationId, providerToolCallId: input.startEvent.toolUseId, toolName: input.tool.name, @@ -2109,6 +2187,7 @@ export class ToolRuntime { const buildResponseEvent = ( result: unknown, isError: boolean, + modelProjection: DurableToolResultProjection, durationMs: number | undefined, ts: number, ): RuntimeEvent => ({ @@ -2129,6 +2208,7 @@ export class ToolRuntime { name: input.tool.name, result, ...(isError ? { isError: true } : {}), + modelProjection, }, refs: { operationId, @@ -2146,9 +2226,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 +2255,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 +3335,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 +3344,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 +3797,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__/artifact-attachments.test.ts b/packages/storage/src/__tests__/artifact-attachments.test.ts index a84c3366e9..ddc63dd389 100644 --- a/packages/storage/src/__tests__/artifact-attachments.test.ts +++ b/packages/storage/src/__tests__/artifact-attachments.test.ts @@ -28,9 +28,13 @@ import { type StorageRef } from '@maka/core/events'; import { createArtifactAttachmentResourceReader, createAttachmentByteReader, + 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]); @@ -220,6 +224,76 @@ 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); + }); + }); + + 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(); + 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()]); + const published = await store.list('session-1'); + assert.deepEqual( + 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'), + mimeType: 'image/png', + }); + }); + }); }); function sessionFileRef(relativePath: string, sessionId = 'session-1'): StorageRef { @@ -230,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/__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/artifact-attachments.ts b/packages/storage/src/artifact-attachments.ts index c274c0c628..d281fe8206 100644 --- a/packages/storage/src/artifact-attachments.ts +++ b/packages/storage/src/artifact-attachments.ts @@ -23,6 +23,12 @@ 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'; import type { @@ -30,6 +36,7 @@ import type { ArtifactStore, DurableArtifactAttachmentReader, } from './artifact-store.js'; +import { sanitizeArtifactName } from './artifact-store.js'; export interface ArtifactAttachmentResourceReader { readAttachmentResource( @@ -119,30 +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); } - const artifact = await artifactStore.create({ + 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: input.name, - kind: 'image', - content: input.bytes, + name: sanitizeArtifactName(input.name), + bytes: input.bytes.slice(), mimeType: input.mimeType, - source: 'tool_result', }); - return { - kind: 'session_file', - sessionId: input.sessionId, - relativePath: artifact.id, - }; + const id = `image_${createHash('sha256') + .update(accepted.sessionId, 'utf8') + .update('\0', 'utf8') + .update(accepted.turnId, 'utf8') + .update('\0', 'utf8') + .update(accepted.name, 'utf8') + .update('\0', 'utf8') + .update(accepted.mimeType, 'utf8') + .update('\0', 'utf8') + .update(accepted.bytes) + .digest('hex')}`; + let publication: Promise | undefined; + const ref = Object.freeze({ + kind: 'session_file' as const, + sessionId: accepted.sessionId, + relativePath: 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_projection', + }) + .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'; 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(`