You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by ku...@apache.org on 2026/09/01 14:35:31 UTC

(maka) 01/02: feat(runtime-host): write Read images to Session context

This is an automated email from the ASF dual-hosted git repository.

likun666661 pushed a commit to branch refactor/read-image-context-writer
in repository https://gitbox.apache.org/repos/asf/maka.git

commit d7b40afe0b4feb94c1e6757ad11d486274e93c8a
Author: likun666661 <90...@users.noreply.github.com>
AuthorDate: Sat Aug 29 22:29:54 2026 +0800

    feat(runtime-host): write Read images to Session context
---
 .../src/__tests__/execution-composition.test.ts    |  4 +-
 .../runtime-host/src/__tests__/protocol.test.ts    |  4 ++
 .../session-retirement-coordinator.test.ts         | 13 ++--
 packages/runtime-host/src/protocol/index.ts        |  3 +-
 .../src/server/execution-composition.ts            | 69 ++++++++++---------
 .../src/server/session-retirement-coordinator.ts   |  9 +--
 .../src/server/session-revision-coordinator.ts     | 32 +++++++++
 .../src/server/session-sidecar-purge.ts            |  3 +
 .../__tests__/builtin-tools-file-worker.test.ts    | 15 +++--
 .../src/__tests__/conversation-copy.test.ts        | 77 +++++++++++-----------
 .../src/__tests__/filesystem-authority.test.ts     |  2 +-
 packages/runtime/src/builtin-tools.ts              |  8 +--
 packages/runtime/src/conversation-copy.ts          | 50 ++++++++++++--
 13 files changed, 193 insertions(+), 96 deletions(-)

diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts
index b43bafeccb..a427b76a36 100644
--- a/packages/runtime-host/src/__tests__/execution-composition.test.ts
+++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts
@@ -103,7 +103,7 @@ test('production composition owns the long-term memory database lifecycle', asyn
   });
 });
 
-test('production composition reaches Ready when the optional context reader cannot open', async () => {
+test('production composition reaches Ready when the optional context Store cannot open', async () => {
   await withCompositionRoot(async ({ root, owner }) => {
     await mkdir(join(root, CONTEXT_OFFLOAD_DATABASE_NAME));
     const originalConsoleError = console.error;
@@ -114,7 +114,7 @@ test('production composition reaches Ready when the optional context reader cann
       composition = await createExecutionRuntimeHostComposition(compositionContext(owner));
       assert.equal(composition.workspaceExecution.state, 'ready');
       assert.equal(
-        diagnostics.some((message) => message.includes('optional context-offload reader')),
+        diagnostics.some((message) => message.includes('optional context-offload Store')),
         true,
       );
     } finally {
diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts
index db9f2d70f6..ca477086b0 100644
--- a/packages/runtime-host/src/__tests__/protocol.test.ts
+++ b/packages/runtime-host/src/__tests__/protocol.test.ts
@@ -140,6 +140,10 @@ describe('Runtime Host bootstrap protocol', () => {
     assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 78);
   });
 
+  test('publishes a new compatibility epoch for Read image Session context refs', () => {
+    assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 87);
+  });
+
   test('rejects the legacy connection update result in the current compatibility epoch', () => {
     assert.throws(
       () =>
diff --git a/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts
index affd668029..ed1b35b5db 100644
--- a/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts
+++ b/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts
@@ -239,7 +239,7 @@ describe('Host Session retirement coordinator', () => {
         'parent retirement cleanup did not converge',
       );
       assert.deepEqual(new Set(harness.actions.purgedArtifacts), new Set(harness.familyIds));
-      assert.deepEqual(new Set(harness.actions.checkedContext), new Set(harness.familyIds));
+      assert.deepEqual(new Set(harness.actions.retiredContext), new Set(harness.familyIds));
     });
   });
 
@@ -1027,7 +1027,7 @@ interface RetirementActions {
   readonly retiredCapabilities: string[];
   readonly retiredMessages: string[];
   readonly purgedArtifacts: string[];
-  readonly checkedContext: string[];
+  readonly retiredContext: string[];
   readonly purgedTasks: string[];
   readonly purgedOperationalState: string[];
   readonly purgedAgentGraphs: string[];
@@ -1066,7 +1066,7 @@ async function withHarness(
       retiredCapabilities: [],
       retiredMessages: [],
       purgedArtifacts: [],
-      checkedContext: [],
+      retiredContext: [],
       purgedTasks: [],
       purgedOperationalState: [],
       purgedAgentGraphs: [],
@@ -1232,8 +1232,11 @@ async function withHarness(
           actions.purgedTasks.push(sessionId);
         },
       },
-      assertNoContextOffloadReferences: async (sessionIds) => {
-        actions.checkedContext.push(...sessionIds);
+      contextOffload: {
+        retireSession: async (sessionId) => {
+          actions.retiredContext.push(sessionId);
+          return { releasedReferences: 0, releasedLogicalBytes: 0 };
+        },
       },
       purgeOperationalState: async (sessionId) => {
         actions.purgedOperationalState.push(sessionId);
diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts
index 8aff63a16a..84dcd3ba91 100644
--- a/packages/runtime-host/src/protocol/index.ts
+++ b/packages/runtime-host/src/protocol/index.ts
@@ -95,7 +95,8 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const;
 export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const;
 // Increment when the same protocol version no longer guarantees safe Client-Host
 // interoperability. Mismatches are rejected before domain commands are admitted.
-export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 87 as const;
+export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 88 as const;
+// 88: Read image tool results may carry durable `session_context` refs.
 // 87: The connection catalog projects each model as the Host resolved it —
 // a `catalog_entry` item per model, counted by the connection header. Clients
 // render those entries instead of merging the stored row against their own
diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts
index e5fc5a826f..1297a2cb41 100644
--- a/packages/runtime-host/src/server/execution-composition.ts
+++ b/packages/runtime-host/src/server/execution-composition.ts
@@ -77,14 +77,9 @@ import {
 import { type MakaTool } from '@maka/runtime/tool-runtime';
 import { type RuntimeHostedRootAuthority } from '@maka/runtime/message-authority';
 import { createAgentGraphControlStore } from '@maka/storage/agent-graph-control-store';
-import {
-  createArtifactAttachmentResourceReader,
-  createReadImageSnapshotter,
-} from '@maka/storage/artifact-stores';
-import {
-  isSessionNotFoundError,
-  SessionMetadataConflictError,
-} from '@maka/storage/execution-stores';
+import { createArtifactAttachmentResourceReader } from '@maka/storage/artifact-stores';
+import { createReadImageSnapshotStore } from '@maka/storage/read-image-snapshot-store';
+import { isSessionNotFoundError } from '@maka/storage/execution-stores';
 import { createExternalSessionAdapterRegistry } from '@maka/storage/external-sessions';
 import { createGitWorktreeChildExecutor } from '@maka/storage/git-worktree-child-executor';
 import { runWithStorageRootLease } from '@maka/storage/root-authority';
@@ -207,15 +202,16 @@ export interface ExecutionRuntimeHostComposition extends RuntimeHostComposition
   readonly plugins: HostPluginPlatform;
 }
 
-const CONTEXT_OFFLOAD_READER_LIMITS: ContextOffloadLimits = Object.freeze({
+const GIBIBYTE = 1024 * 1024 * 1024;
+const CONTEXT_OFFLOAD_LIMITS: ContextOffloadLimits = Object.freeze({
   ownerMaxBytes: Object.freeze({
     read_image_snapshot: MAX_READ_IMAGE_BYTES,
     tool_result_archive: 0,
   }),
-  // This expand slice opens only the reader path. Zero quotas make accidental
-  // non-empty puts fail closed until the writer/lifecycle cutover lands.
-  sessionLogicalBytes: 0,
-  workspacePhysicalBytes: 0,
+  // Read images are bounded individually and logically per Session. Physical
+  // bytes are content-addressed across Sessions and bounded per workspace.
+  sessionLogicalBytes: GIBIBYTE,
+  workspacePhysicalBytes: 20 * GIBIBYTE,
 });
 
 export interface CreateExecutionRuntimeHostCompositionOptions {
@@ -244,7 +240,7 @@ export async function createExecutionRuntimeHostComposition(
   dependencies: ExecutionRuntimeHostCompositionDependencies = {},
 ): Promise<ExecutionRuntimeHostComposition> {
   const storage = await openStorageWriterComposition(context.owner.lease, {
-    contextOffloadLimits: CONTEXT_OFFLOAD_READER_LIMITS,
+    contextOffloadLimits: CONTEXT_OFFLOAD_LIMITS,
     afterRuntimePolicyOpened: async (stores) => {
       if (options.bootstrapRuntimePolicy !== false) {
         await ensureBootstrapRuntimePolicy({
@@ -260,7 +256,7 @@ export async function createExecutionRuntimeHostComposition(
   });
   if (storage.contextOffloadUnavailable) {
     console.error(
-      `[runtime-host] optional context-offload reader could not be opened: ${generalizedErrorMessage(storage.contextOffloadUnavailable.cause)}`,
+      `[runtime-host] optional context-offload Store could not be opened: ${generalizedErrorMessage(storage.contextOffloadUnavailable.cause)}`,
     );
   }
   const stores = storage.execution;
@@ -292,6 +288,17 @@ export async function createExecutionRuntimeHostComposition(
     const openedContextOffloadReader = openedContextOffloadStore
       ? createInteractiveContextOffloadReader(openedContextOffloadStore)
       : undefined;
+    const contextOffloadRetirement = openedContextOffloadStore
+      ? openedContextOffloadStore
+      : storage.contextOffloadUnavailable
+        ? {
+            retireSession: async (_sessionId: string): Promise<never> => {
+              throw new Error('Context-offload Store is unavailable during Session retirement', {
+                cause: storage.contextOffloadUnavailable?.cause,
+              });
+            },
+          }
+        : undefined;
     const openedUsageStores = storage.usage;
     const openedShellRunStore = storage.shellRuns;
     const worktreeChildExecutor = createGitWorktreeChildExecutor({
@@ -401,7 +408,21 @@ export async function createExecutionRuntimeHostComposition(
       }),
       backgroundTasks: runtimeResources,
       ptyControls: runtimeResources,
-      snapshotImage: createReadImageSnapshotter(openedArtifactStore),
+      ...(openedContextOffloadStore
+        ? {
+            snapshotImage: async (input: {
+              readonly sessionId: string;
+              readonly ownerId: string;
+              readonly bytes: Uint8Array;
+              readonly mimeType: string;
+            }) =>
+              createReadImageSnapshotStore(openedContextOffloadStore, input.sessionId).snapshot({
+                ownerId: input.ownerId,
+                bytes: input.bytes,
+                mimeType: input.mimeType,
+              }),
+          }
+        : {}),
       ...(sandboxManager ? { sandboxManager } : {}),
       ...(filesystemWorker ? { filesystemWorker } : {}),
     };
@@ -1539,6 +1560,7 @@ export async function createExecutionRuntimeHostComposition(
       stores,
       artifacts: openedArtifactStore,
       sessionTodo: sessionTodoStore,
+      ...(contextOffloadRetirement ? { contextOffload: contextOffloadRetirement } : {}),
       manager,
       admission: sessionAdmission,
       continuity: continuityCoordinator,
@@ -1563,20 +1585,7 @@ export async function createExecutionRuntimeHostComposition(
       continuity: continuityCoordinator,
       artifacts: openedArtifactStore,
       sessionTodo: sessionTodoStore,
-      assertNoContextOffloadReferences: async (sessionIds) => {
-        if (!openedContextOffloadStore) {
-          throw new Error('Context-offload reader is unavailable during Session removal', {
-            cause: storage.contextOffloadUnavailable?.cause,
-          });
-        }
-        for (const sessionId of sessionIds) {
-          if ((await openedContextOffloadStore.usage(sessionId)).references > 0) {
-            throw new SessionMetadataConflictError(
-              'Session removal does not support Session context references yet',
-            );
-          }
-        }
-      },
+      ...(contextOffloadRetirement ? { contextOffload: contextOffloadRetirement } : {}),
       purgeOperationalState: async (sessionId) => {
         await stores.purgeConversationOperationalState(sessionId);
         await openedPlanStore.purgeSessionState(sessionId);
diff --git a/packages/runtime-host/src/server/session-retirement-coordinator.ts b/packages/runtime-host/src/server/session-retirement-coordinator.ts
index 72c6e9632f..52b672de96 100644
--- a/packages/runtime-host/src/server/session-retirement-coordinator.ts
+++ b/packages/runtime-host/src/server/session-retirement-coordinator.ts
@@ -32,6 +32,7 @@ import {
 } from '@maka/storage/execution-stores';
 import { type SessionManager } from '@maka/runtime/session-manager';
 import type { InteractiveSessionTodoWriter } from '@maka/storage/session-todo-authority';
+import type { InteractiveContextOffloadWriter } from '@maka/storage/context-offload-store';
 import {
   type OperationOutcome,
   type SessionCatalogItem,
@@ -122,7 +123,7 @@ export interface HostSessionRetirementCoordinatorOptions {
   readonly continuity: RetirementContinuity;
   readonly artifacts: Pick<InteractiveArtifactStoreWriter, 'purgeSessionArtifacts'>;
   readonly sessionTodo: Pick<InteractiveSessionTodoWriter, 'purgeSessionState'>;
-  readonly assertNoContextOffloadReferences?: (sessionIds: readonly string[]) => Promise<void>;
+  readonly contextOffload?: Pick<InteractiveContextOffloadWriter, 'retireSession'>;
   readonly purgeOperationalState: (sessionId: string) => Promise<void>;
   readonly purgeAgentGraphState: (sessionId: string) => Promise<void>;
   readonly worktrees?: Pick<SubagentWorktreeExecutor, 'retire'>;
@@ -194,7 +195,7 @@ export class HostSessionRetirementCoordinator {
   readonly #continuity: RetirementContinuity;
   readonly #artifacts: HostSessionRetirementCoordinatorOptions['artifacts'];
   readonly #sessionTodo: HostSessionRetirementCoordinatorOptions['sessionTodo'];
-  readonly #assertNoContextOffloadReferences: HostSessionRetirementCoordinatorOptions['assertNoContextOffloadReferences'];
+  readonly #contextOffload: HostSessionRetirementCoordinatorOptions['contextOffload'];
   readonly #purgeOperationalState: HostSessionRetirementCoordinatorOptions['purgeOperationalState'];
   readonly #purgeAgentGraphState: HostSessionRetirementCoordinatorOptions['purgeAgentGraphState'];
   readonly #worktrees: HostSessionRetirementCoordinatorOptions['worktrees'];
@@ -222,7 +223,7 @@ export class HostSessionRetirementCoordinator {
     this.#continuity = options.continuity;
     this.#artifacts = options.artifacts;
     this.#sessionTodo = options.sessionTodo;
-    this.#assertNoContextOffloadReferences = options.assertNoContextOffloadReferences;
+    this.#contextOffload = options.contextOffload;
     this.#purgeOperationalState = options.purgeOperationalState;
     this.#purgeAgentGraphState = options.purgeAgentGraphState;
     this.#worktrees = options.worktrees;
@@ -338,7 +339,6 @@ export class HostSessionRetirementCoordinator {
           if (plan.archive.sessionIds.length > 0) {
             archiveHandles = await this.#prepareRetirement(plan.archive, 'archive');
           }
-          await this.#assertNoContextOffloadReferences?.(plan.remove.sessionIds);
           const allSessionIds = [...plan.remove.sessionIds, ...plan.archive.sessionIds];
           await this.#finalizeWorkspacePatches(allSessionIds);
           await this.#disposeBackends(allSessionIds);
@@ -725,6 +725,7 @@ export class HostSessionRetirementCoordinator {
         {
           artifacts: this.#artifacts,
           sessionTodo: this.#sessionTodo,
+          ...(this.#contextOffload ? { contextOffload: this.#contextOffload } : {}),
           purgeOperationalState: this.#purgeOperationalState,
         },
         sessionId,
diff --git a/packages/runtime-host/src/server/session-revision-coordinator.ts b/packages/runtime-host/src/server/session-revision-coordinator.ts
index 16c918fff4..dd051dcfc8 100644
--- a/packages/runtime-host/src/server/session-revision-coordinator.ts
+++ b/packages/runtime-host/src/server/session-revision-coordinator.ts
@@ -35,6 +35,7 @@ import {
   archivedToolResultContainsConversationOwnedReferences,
   cloneConversationRuntimeLedger,
   collectConversationCopyLinkedChildReferences,
+  collectConversationCopySessionContextRefIds,
   collectConversationCopySessionFileRefs,
   createConversationCopySlice,
   prepareConversationRuntimeLedgerCopy,
@@ -61,6 +62,7 @@ import {
   authenticateInteractiveSessionTodoWriter,
   type InteractiveSessionTodoWriter,
 } from '@maka/storage/session-todo-authority';
+import type { InteractiveContextOffloadWriter } from '@maka/storage/context-offload-store';
 import type {
   OperationOutcome,
   SessionConversationCopyInput,
@@ -106,6 +108,10 @@ export interface HostSessionRevisionCoordinatorOptions {
   readonly stores: ExecutionStoresWriter<'interactive'>;
   readonly artifacts: InteractiveArtifactStoreWriter;
   readonly sessionTodo: InteractiveSessionTodoWriter;
+  readonly contextOffload?: Pick<
+    InteractiveContextOffloadWriter,
+    'copyReferences' | 'retireSession'
+  >;
   readonly manager: SessionManager;
   readonly admission: SessionAdmissionGate;
   readonly continuity: SessionContinuityCoordinator;
@@ -503,6 +509,28 @@ export class HostSessionRevisionCoordinator {
           )
           .map(({ descriptor, serializedResult }) => [descriptor.artifactId, serializedResult]),
       );
+      const sourceContextRefIds = collectConversationCopySessionContextRefIds({
+        sourceSessionId: input.sourceSessionId,
+        copiedMessages: slice.messages,
+        plan,
+      });
+      if (sourceContextRefIds.length > 0 && !this.options.contextOffload) {
+        throw new Error('Session context copy authority is unavailable');
+      }
+      const contextCopy =
+        sourceContextRefIds.length === 0
+          ? { ok: true as const, copied: [] }
+          : await this.options.contextOffload!.copyReferences({
+              sourceSessionId: input.sourceSessionId,
+              targetSessionId: input.targetSessionId,
+              references: sourceContextRefIds.map((sourceRefId) => ({
+                sourceRefId,
+                targetOwner: { kind: 'read_image_snapshot', ownerId: sourceRefId },
+              })),
+            });
+      if (!contextCopy.ok) {
+        throw new Error(`Session context references could not be copied: ${contextCopy.reason}`);
+      }
       const artifactCopy = await this.#artifacts.copyConversationArtifacts({
         sourceSessionId: input.sourceSessionId,
         targetSessionId: input.targetSessionId,
@@ -528,6 +556,9 @@ export class HostSessionRevisionCoordinator {
         targetSessionId: input.targetSessionId,
         artifactIds: artifactCopy.artifactIds,
         relativePaths: artifactCopy.relativePaths,
+        contextRefs: new Map(
+          contextCopy.copied.map(({ sourceRefId, targetRefId }) => [sourceRefId, targetRefId]),
+        ),
         linkedChildren:
           kind === 'side_conversation'
             ? {
@@ -819,6 +850,7 @@ export class HostSessionRevisionCoordinator {
       {
         artifacts: this.#artifacts,
         sessionTodo: this.#sessionTodo,
+        ...(this.options.contextOffload ? { contextOffload: this.options.contextOffload } : {}),
         purgeOperationalState: (sessionId) =>
           this.#stores.purgeConversationOperationalState(sessionId),
       },
diff --git a/packages/runtime-host/src/server/session-sidecar-purge.ts b/packages/runtime-host/src/server/session-sidecar-purge.ts
index 54a09e4676..59484fcf12 100644
--- a/packages/runtime-host/src/server/session-sidecar-purge.ts
+++ b/packages/runtime-host/src/server/session-sidecar-purge.ts
@@ -19,10 +19,12 @@
 
 import type { InteractiveArtifactStoreWriter } from '@maka/storage/artifact-stores';
 import type { InteractiveSessionTodoWriter } from '@maka/storage/session-todo-authority';
+import type { InteractiveContextOffloadWriter } from '@maka/storage/context-offload-store';
 
 export interface SessionSidecarPurgeAuthority {
   readonly artifacts: Pick<InteractiveArtifactStoreWriter, 'purgeSessionArtifacts'>;
   readonly sessionTodo: Pick<InteractiveSessionTodoWriter, 'purgeSessionState'>;
+  readonly contextOffload?: Pick<InteractiveContextOffloadWriter, 'retireSession'>;
   readonly purgeOperationalState: (sessionId: string) => Promise<void>;
 }
 
@@ -33,6 +35,7 @@ export async function purgeSessionSidecars(
   const outcomes = await Promise.allSettled([
     authority.artifacts.purgeSessionArtifacts(sessionId),
     authority.sessionTodo.purgeSessionState(sessionId),
+    ...(authority.contextOffload ? [authority.contextOffload.retireSession(sessionId)] : []),
     authority.purgeOperationalState(sessionId),
   ]);
   const failures = outcomes.flatMap((outcome) =>
diff --git a/packages/runtime/src/__tests__/builtin-tools-file-worker.test.ts b/packages/runtime/src/__tests__/builtin-tools-file-worker.test.ts
index bc0eaa5886..fe9c9a1c24 100644
--- a/packages/runtime/src/__tests__/builtin-tools-file-worker.test.ts
+++ b/packages/runtime/src/__tests__/builtin-tools-file-worker.test.ts
@@ -169,6 +169,7 @@ describe('builtin file tools use the sandboxed worker', () => {
   test('uses one worker read operation for image paths', async () => {
     const cwd = await temporaryDirectory('maka-file-worker-cwd-');
     const calls: FilesystemWorkerExecuteInput[] = [];
+    let snapshotOwnerId: string | undefined;
     const tools = buildBuiltinTools({
       filesystemWorker: {
         execute: async (input) => {
@@ -176,11 +177,14 @@ describe('builtin file tools use the sandboxed worker', () => {
           return { kind: 'read_image', base64: 'iVBORw0KGgo=', mimeType: 'image/png' };
         },
       },
-      snapshotImage: async () => ({
-        kind: 'session_file',
-        sessionId: 'session-1',
-        relativePath: 'artifact-1',
-      }),
+      snapshotImage: async (input) => {
+        snapshotOwnerId = input.ownerId;
+        return {
+          kind: 'session_context',
+          sessionId: 'session-1',
+          refId: 'context-1',
+        };
+      },
       sandboxPlatform: 'darwin',
     });
 
@@ -188,6 +192,7 @@ describe('builtin file tools use the sandboxed worker', () => {
 
     assert.equal(calls.length, 1);
     assert.deepEqual(calls[0]?.operation, { kind: 'read', path: 'image.png', offset: 1, limit: 1 });
+    assert.equal(snapshotOwnerId, 'tool-Read');
   });
 
   test('serializes writes through real and symlinked cwd paths', async () => {
diff --git a/packages/runtime/src/__tests__/conversation-copy.test.ts b/packages/runtime/src/__tests__/conversation-copy.test.ts
index 36bed08f12..7c1504ad39 100644
--- a/packages/runtime/src/__tests__/conversation-copy.test.ts
+++ b/packages/runtime/src/__tests__/conversation-copy.test.ts
@@ -44,6 +44,7 @@ import {
   archivedToolResultContainsConversationOwnedReferences,
   cloneConversationRuntimeLedger,
   collectConversationCopyLinkedChildReferences,
+  collectConversationCopySessionContextRefIds,
   collectConversationCopySessionFileRefs,
   createConversationCopySlice,
   prepareConversationRuntimeLedgerCopy,
@@ -721,6 +722,17 @@ test('conversation copy rewrites owned references without changing opaque tool p
             relativePath: 'session-source/artifact-source-file.txt',
           },
         },
+        {
+          kind: 'image',
+          name: 'snapshot.png',
+          mimeType: 'image/png',
+          bytes: 4,
+          ref: {
+            kind: 'session_context',
+            sessionId: 'session-source',
+            refId: 'context-source',
+          },
+        },
       ],
     },
     {
@@ -800,6 +812,7 @@ test('conversation copy rewrites owned references without changing opaque tool p
     relativePaths: new Map([
       ['session-source/artifact-source-file.txt', 'session-target/artifact-target-file.txt'],
     ]),
+    contextRefs: new Map([['context-source', 'context-target']]),
     runIds: new Map([['run-source', 'run-target']]),
     invocationIds: new Map([['invocation-source', 'invocation-target']]),
     runtimeEventIds: new Map([['event-source', 'event-target']]),
@@ -812,6 +825,32 @@ test('conversation copy rewrites owned references without changing opaque tool p
     sessionId: 'session-target',
     relativePath: 'session-target/artifact-target-file.txt',
   });
+  assert.deepEqual(rewritten[0]?.type === 'user' ? rewritten[0].attachments?.[1]?.ref : undefined, {
+    kind: 'session_context',
+    sessionId: 'session-target',
+    refId: 'context-target',
+  });
+  assert.deepEqual(
+    collectConversationCopySessionContextRefIds({
+      sourceSessionId: 'session-source',
+      copiedMessages: messages,
+      plan: {
+        sourceSessionId: 'session-source',
+        copyTurnIds: ['turn-1'],
+        inlineRuntimeEvents: [],
+        runs: [],
+      },
+    }),
+    ['context-source'],
+  );
+  assert.throws(
+    () =>
+      rewriteConversationCopyMessage(messages[0]!, {
+        ...references,
+        contextRefs: new Map(),
+      }),
+    /missing Session context context-source/,
+  );
   const userMessage = messages[0];
   assert.equal(userMessage?.type, 'user');
   if (userMessage?.type !== 'user') return;
@@ -1004,44 +1043,6 @@ test('conversation copy rewrites owned references without changing opaque tool p
   );
 });
 
-test('reader-only conversation copy rejects source-owned Session context refs', () => {
-  const message: StoredMessage = {
-    type: 'user',
-    id: 'user-context',
-    turnId: 'turn-1',
-    ts: 1,
-    text: 'context',
-    attachments: [
-      {
-        kind: 'image',
-        name: 'snapshot.png',
-        mimeType: 'image/png',
-        bytes: 4,
-        ref: {
-          kind: 'session_context',
-          sessionId: 'session-source',
-          refId: 'context-source',
-        },
-      },
-    ],
-  };
-  assert.throws(
-    () =>
-      rewriteConversationCopyMessage(message, {
-        mode: 'exact',
-        sourceSessionId: 'session-source',
-        targetSessionId: 'session-target',
-        artifactIds: new Map(),
-        relativePaths: new Map(),
-        linkedChildren: { mode: 'reject' },
-        runIds: new Map(),
-        runtimeEventIds: new Map(),
-        providerTraceIds: new Map(),
-      }),
-    /does not support Session context references yet/,
-  );
-});
-
 test('conversation copy rejects continuation authority selected through the child-run closure', async () => {
   const parent = agentRunHeader({ runId: 'run-parent', turnId: 'turn-parent' });
   const child = agentRunHeader({
diff --git a/packages/runtime/src/__tests__/filesystem-authority.test.ts b/packages/runtime/src/__tests__/filesystem-authority.test.ts
index b70ff74418..c755002fa4 100644
--- a/packages/runtime/src/__tests__/filesystem-authority.test.ts
+++ b/packages/runtime/src/__tests__/filesystem-authority.test.ts
@@ -435,7 +435,7 @@ describe('file tools follow the execution boundary', () => {
         },
         snapshotImage: async (input) => {
           snapshots.push(input.bytes);
-          return { kind: 'session_file', sessionId: input.sessionId, relativePath: 'artifact-1' };
+          return { kind: 'session_context', sessionId: input.sessionId, refId: 'context-1' };
         },
       });
 
diff --git a/packages/runtime/src/builtin-tools.ts b/packages/runtime/src/builtin-tools.ts
index dbb1d7dc65..37a2f43011 100644
--- a/packages/runtime/src/builtin-tools.ts
+++ b/packages/runtime/src/builtin-tools.ts
@@ -184,11 +184,10 @@ export interface BuildBuiltinToolsOptions {
   sandboxPlatform?: SandboxPlatform;
   snapshotImage?: (input: {
     sessionId: string;
-    turnId: string;
-    name: string;
+    ownerId: string;
     bytes: Uint8Array;
     mimeType: string;
-  }) => Promise<Extract<StorageRef, { kind: 'session_file' }>>;
+  }) => Promise<Extract<StorageRef, { kind: 'session_context' }>>;
 }
 
 export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaTool[] {
@@ -400,8 +399,7 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT
             throw new Error('Read image snapshots are not available in this toolset.');
           const ref = await options.snapshotImage({
             sessionId,
-            turnId: ctx.turnId,
-            name: basename(path),
+            ownerId: ctx.toolCallId,
             bytes: result.bytes,
             mimeType: result.mimeType,
           });
diff --git a/packages/runtime/src/conversation-copy.ts b/packages/runtime/src/conversation-copy.ts
index f013c89302..fe42b81b12 100644
--- a/packages/runtime/src/conversation-copy.ts
+++ b/packages/runtime/src/conversation-copy.ts
@@ -25,7 +25,7 @@ import type {
 } from '@maka/core/agent-run';
 import type { RuntimeEvent } from '@maka/core/runtime-event';
 import type { RuntimeEventStore } from '@maka/core/runtime-event-store';
-import type { StorageRef, ToolResultContent } from '@maka/core/events';
+import { isStorageRef, type StorageRef, type ToolResultContent } from '@maka/core/events';
 import { parseAttachmentResourceRef } from '@maka/core/attachments';
 import { markPersisted } from '@maka/core/persisted-value';
 import type { StoredMessage } from '@maka/core/session';
@@ -104,6 +104,7 @@ export type ConversationCopyArtifactReferenceMap =
       readonly mode: 'exact';
       readonly artifactIds: ReadonlyMap<string, string>;
       readonly relativePaths: ReadonlyMap<string, string>;
+      readonly contextRefs?: ReadonlyMap<string, string>;
       readonly linkedChildren:
         | { readonly mode: 'reject' }
         | {
@@ -159,6 +160,35 @@ export interface ConversationRuntimeLedgerCopyPlan {
   }[];
 }
 
+/** Finds durable Session context references that the exact copy will rewrite. */
+export function collectConversationCopySessionContextRefIds(input: {
+  readonly sourceSessionId: string;
+  readonly copiedMessages: readonly StoredMessage[];
+  readonly plan: ConversationRuntimeLedgerCopyPlan;
+}): readonly string[] {
+  const refIds = new Set<string>();
+  const seen = new WeakSet<object>();
+  const visit = (value: unknown): void => {
+    if (isStorageRef(value)) {
+      if (value.kind === 'session_context' && value.sessionId === input.sourceSessionId) {
+        refIds.add(value.refId);
+      }
+      return;
+    }
+    if (typeof value !== 'object' || value === null || seen.has(value)) return;
+    seen.add(value);
+    if (Array.isArray(value)) {
+      for (const item of value) visit(item);
+      return;
+    }
+    for (const item of Object.values(value)) visit(item);
+  };
+  visit(input.copiedMessages);
+  visit(input.plan.inlineRuntimeEvents);
+  for (const run of input.plan.runs) visit(run.runtimeEvents);
+  return [...refIds].sort();
+}
+
 export interface CloneConversationRuntimeLedgerResult {
   readonly copiedMessages: readonly StoredMessage[];
   readonly runIdMap: readonly {
@@ -1779,12 +1809,22 @@ function rewriteStorageRef(
   ref: StorageRef,
   references: ConversationCopyArtifactReferenceMap,
 ): StorageRef {
-  if (ref.kind === 'session_context' && ref.sessionId === references.sourceSessionId) {
-    if (references.mode === 'preserve_external') return ref;
-    throw new Error('Conversation copy does not support Session context references yet');
+  if (
+    (ref.kind !== 'session_file' && ref.kind !== 'session_context') ||
+    ref.sessionId !== references.sourceSessionId
+  ) {
+    return ref;
   }
-  if (ref.kind !== 'session_file' || ref.sessionId !== references.sourceSessionId) return ref;
   if (references.mode === 'preserve_external') return ref;
+  if (ref.kind === 'session_context') {
+    const refId = references.contextRefs?.get(ref.refId);
+    if (!refId) throw new Error(`Conversation copy is missing Session context ${ref.refId}`);
+    return {
+      ...ref,
+      sessionId: references.targetSessionId,
+      refId,
+    };
+  }
   const artifactId = references.artifactIds.get(ref.relativePath);
   if (artifactId) {
     return {


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org