diff --git a/apps/desktop/src/main/__tests__/quote-companion-cleanup.test.ts b/apps/desktop/src/main/__tests__/quote-companion-cleanup.test.ts
index 90a268e9f6..0c9b9cac64 100644
--- a/apps/desktop/src/main/__tests__/quote-companion-cleanup.test.ts
+++ b/apps/desktop/src/main/__tests__/quote-companion-cleanup.test.ts
@@ -38,6 +38,44 @@ afterEach(async () => {
});
describe('quote companion cleanup authority', () => {
+ it('forgets a known rejected creation without trying to resume or remove it', async () => {
+ const workspaceRoot = await createWorkspace();
+ let resumes = 0;
+ let removals = 0;
+ const authority = createSessionCopyCleanupAuthority({
+ workspaceRoot,
+ resumeSessionCopy: async () => {
+ resumes += 1;
+ },
+ removeSession: async () => {
+ removals += 1;
+ },
+ });
+ const creation = {
+ sessionId: 'fork-rejected',
+ kind: 'branch' as const,
+ sourceSessionId: 'source-session',
+ sourceTurnId: 'source-turn',
+ intent: 'side_conversation' as const,
+ ownerId: 'web-contents:1',
+ };
+
+ await assert.rejects(
+ authority.ownCreation(creation, async () => {
+ throw new Error('session busy');
+ }),
+ /session busy/,
+ );
+ assert.deepEqual(await readPendingIds(workspaceRoot), ['fork-rejected']);
+
+ await authority.rejectCreation('fork-rejected');
+
+ assert.deepEqual(await readPendingIds(workspaceRoot), []);
+ assert.equal(resumes, 0);
+ assert.equal(removals, 0);
+ assert.equal(await authority.ownCreation(creation, async () => 'retried'), 'retried');
+ });
+
it('releases a rejected creation lease so the same identity can retry', async () => {
const workspaceRoot = await createWorkspace();
let removalFails = true;
@@ -124,6 +162,7 @@ describe('quote companion cleanup authority', () => {
kind: 'branch',
sourceSessionId: 'source-session',
sourceTurnId: 'source-turn',
+ intent: 'side_conversation',
ownerId: 'web-contents:2',
},
async () => {
@@ -138,7 +177,7 @@ describe('quote companion cleanup authority', () => {
workspaceRoot,
processId: 'process-after-crash',
resumeSessionCopy: async (creation) => {
- events.push(`resume:${creation.sessionId}:${creation.sourceTurnId}`);
+ events.push(`resume:${creation.sessionId}:${creation.sourceTurnId}:${creation.intent}`);
},
removeSession: async (sessionId) => {
events.push(`remove:${sessionId}`);
@@ -150,7 +189,7 @@ describe('quote companion cleanup authority', () => {
failed: [],
});
assert.deepEqual(events, [
- 'resume:fork-unknown-create:source-turn',
+ 'resume:fork-unknown-create:source-turn:side_conversation',
'remove:fork-unknown-create',
]);
assert.deepEqual(await readPendingIds(workspaceRoot), []);
diff --git a/apps/desktop/src/main/__tests__/quote-companion-disposal.test.ts b/apps/desktop/src/main/__tests__/quote-companion-disposal.test.ts
index 6bebbd8a21..8553509e2d 100644
--- a/apps/desktop/src/main/__tests__/quote-companion-disposal.test.ts
+++ b/apps/desktop/src/main/__tests__/quote-companion-disposal.test.ts
@@ -23,6 +23,7 @@ import type { SessionSummary, TurnRecord } from '@maka/core/session';
import {
abandonPendingCompanionCopy,
createFakeWorkbarServices,
+ ensureCompanionFork,
performCompanionTurn,
type PerformCompanionTurnDeps,
type WorkbarServices,
@@ -92,6 +93,26 @@ afterEach(async () => {
});
describe('quote companion disposal fencing', () => {
+ it('preserves a retryable busy reason from Side Conversation creation', async () => {
+ const defaults = createFakeWorkbarServices();
+ const sideChat = {
+ ...defaults.sideChat,
+ listTurns: async () => [settledTurn('source-turn')],
+ branchFromTurn: async () => ({ ok: false as const, reason: 'session_busy' as const }),
+ };
+
+ assert.deepEqual(
+ await ensureCompanionFork({
+ api: sideChat,
+ sourceSession,
+ panelId,
+ name: 'Side chat',
+ isDisposed: () => false,
+ }),
+ { status: 'error', code: 'fork_source_busy' },
+ );
+ });
+
it('does not start a send when the panel was disposed after fork setup', async () => {
let sends = 0;
let armed = 0;
@@ -152,7 +173,7 @@ describe('quote companion disposal fencing', () => {
const sideChat = {
...defaults.sideChat,
listTurns: async () => [settledTurn('source-turn')],
- branchFromTurn: () => pendingFork.promise,
+ branchFromTurn: async () => ({ ok: true as const, session: await pendingFork.promise }),
cleanupSessionCopy: async (sessionId: string) => {
cleaned.push(sessionId);
},
diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts
new file mode 100644
index 0000000000..4070741470
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts
@@ -0,0 +1,261 @@
+/*
+ * 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 { afterEach, test } from 'node:test';
+import { parseHTML } from 'linkedom';
+import { act, createElement } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import type { SessionChangedEvent, SessionSummary, TurnRecord } from '@maka/core/session';
+import {
+ createFakeWorkbarServices,
+ useQuoteCompanion,
+ WorkbarServicesProvider,
+ type WorkbarServices,
+} from '../../renderer/features/workbar/testing.js';
+
+const originalGlobals = {
+ document: globalThis.document,
+ window: globalThis.window,
+ HTMLElement: globalThis.HTMLElement,
+ HTMLIFrameElement: globalThis.HTMLIFrameElement,
+ Event: globalThis.Event,
+ Node: globalThis.Node,
+ IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
+ .IS_REACT_ACT_ENVIRONMENT,
+};
+
+let mountedRoot: Root | undefined;
+const SOURCE_SESSION = session('source-session');
+
+afterEach(async () => {
+ if (mountedRoot) {
+ await act(async () => {
+ mountedRoot?.unmount();
+ await Promise.resolve();
+ });
+ }
+ mountedRoot = undefined;
+ Object.assign(globalThis, originalGlobals);
+});
+
+test('retries a busy Side Conversation at the newest settled boundary and clears its banner', async () => {
+ const parsed = parseHTML('
');
+ const { document, window } = parsed;
+ Object.assign(globalThis, {
+ document,
+ window,
+ HTMLElement: window.HTMLElement,
+ HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {},
+ Event: window.Event,
+ Node: window.Node,
+ IS_REACT_ACT_ENVIRONMENT: true,
+ });
+
+ let listCount = 0;
+ let sessionChange: ((event: SessionChangedEvent) => void) | undefined;
+ let releaseRetry: (() => void) | undefined;
+ const branchInputs: Array<{ sourceTurnId: string; copyId: string }> = [];
+ const defaults = createFakeWorkbarServices();
+ const services: WorkbarServices = {
+ ...defaults,
+ sideChat: {
+ ...defaults.sideChat,
+ listTurns: async () => {
+ listCount += 1;
+ return listCount === 1
+ ? [settledTurn('turn-before-busy')]
+ : [settledTurn('turn-before-busy'), settledTurn('turn-after-busy')];
+ },
+ branchFromTurn: async (_sessionId, input) => {
+ branchInputs.push({ sourceTurnId: input.sourceTurnId, copyId: input.copyId });
+ if (branchInputs.length === 1) {
+ return { ok: false as const, reason: 'session_busy' as const };
+ }
+ await new Promise((resolve) => {
+ releaseRetry = resolve;
+ });
+ return { ok: true as const, session: session('side-conversation') };
+ },
+ subscribeSessionChanges: (handler) => {
+ sessionChange = handler;
+ return () => {
+ if (sessionChange === handler) sessionChange = undefined;
+ };
+ },
+ },
+ };
+ const container = document.querySelector('#root');
+ assert.ok(container);
+ const root = createRoot(container);
+ mountedRoot = root;
+
+ await act(async () => {
+ root.render(
+ createElement(WorkbarServicesProvider, {
+ services,
+ children: createElement(QuoteCompanionProbe),
+ }),
+ );
+ await Promise.resolve();
+ });
+ await waitUntil(() => branchInputs.length === 1 && sessionChange !== undefined);
+ assert.match(container.textContent, /main conversation or a linked task is still running/i);
+ const probe = container.firstElementChild;
+ assert.ok(probe);
+
+ await act(async () => {
+ sessionChange?.({
+ reason: 'turn-status-change',
+ sessionId: 'source-session',
+ turnId: 'turn-after-busy',
+ ts: Date.now(),
+ });
+ await Promise.resolve();
+ });
+ await waitUntil(() => branchInputs.length === 2 && releaseRetry !== undefined);
+ assert.equal(probe.getAttribute('data-preparing'), 'false');
+ assert.match(container.textContent, /main conversation or a linked task is still running/i);
+
+ await act(async () => {
+ releaseRetry?.();
+ await Promise.resolve();
+ });
+ await waitUntil(
+ () => probe.getAttribute('data-companion-id') === 'side-conversation',
+ () =>
+ `branch inputs: ${JSON.stringify(branchInputs)}; companion: ${probe.getAttribute('data-companion-id')}; error: ${probe.getAttribute('data-error')}`,
+ );
+
+ assert.deepEqual(
+ branchInputs.map(({ sourceTurnId }) => sourceTurnId),
+ ['turn-before-busy', 'turn-after-busy'],
+ );
+ assert.notEqual(branchInputs[0]?.copyId, branchInputs[1]?.copyId);
+ assert.equal(probe.getAttribute('data-error'), '');
+});
+
+test('does not restart foreground setup when the source Session object refreshes', async () => {
+ const parsed = parseHTML('');
+ const { document, window } = parsed;
+ Object.assign(globalThis, {
+ document,
+ window,
+ HTMLElement: window.HTMLElement,
+ HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {},
+ Event: window.Event,
+ Node: window.Node,
+ IS_REACT_ACT_ENVIRONMENT: true,
+ });
+
+ let branchCount = 0;
+ const defaults = createFakeWorkbarServices();
+ const services: WorkbarServices = {
+ ...defaults,
+ sideChat: {
+ ...defaults.sideChat,
+ listTurns: async () => [settledTurn('settled-turn')],
+ branchFromTurn: async () => {
+ branchCount += 1;
+ if (branchCount === 1) {
+ return { ok: false as const, reason: 'session_busy' as const };
+ }
+ return await new Promise(() => undefined);
+ },
+ },
+ };
+ const container = document.querySelector('#root');
+ assert.ok(container);
+ const root = createRoot(container);
+ mountedRoot = root;
+
+ const render = (sourceSession: SessionSummary) =>
+ root.render(
+ createElement(WorkbarServicesProvider, {
+ services,
+ children: createElement(QuoteCompanionProbe, { sourceSession }),
+ }),
+ );
+
+ await act(async () => {
+ render(session('source-session'));
+ await Promise.resolve();
+ });
+ const probe = container.firstElementChild;
+ assert.ok(probe);
+ await waitUntil(
+ () => branchCount === 1 && probe.getAttribute('data-preparing') === 'false',
+ );
+
+ await act(async () => {
+ render(session('source-session'));
+ await Promise.resolve();
+ });
+
+ assert.equal(branchCount, 1);
+ assert.equal(probe.getAttribute('data-preparing'), 'false');
+});
+
+function QuoteCompanionProbe(props: { sourceSession?: SessionSummary }) {
+ const companion = useQuoteCompanion({
+ panelId: 'retry-panel',
+ pendingQuotes: [],
+ sourceSession: props.sourceSession ?? SOURCE_SESSION,
+ locale: 'en',
+ onQuotesConsumed: () => undefined,
+ });
+ return createElement('div', {
+ 'data-error': companion.error ?? '',
+ 'data-companion-id': companion.companionSession?.id ?? '',
+ 'data-preparing': String(companion.preparing),
+ }, companion.error);
+}
+
+function session(id: string): SessionSummary {
+ return {
+ id,
+ name: id,
+ isFlagged: false,
+ isArchived: false,
+ labels: [],
+ hasUnread: false,
+ status: 'active',
+ backend: 'ai-sdk',
+ llmConnectionSlug: 'test',
+ connectionLocked: false,
+ model: 'test-model',
+ permissionMode: 'ask',
+ };
+}
+
+function settledTurn(turnId: string): TurnRecord {
+ return { turnId, status: 'completed', partialOutputRetained: false };
+}
+
+async function waitUntil(predicate: () => boolean, diagnostics?: () => string): Promise {
+ for (let attempt = 0; attempt < 50; attempt += 1) {
+ if (predicate()) return;
+ await act(async () => {
+ await new Promise((resolve) => setImmediate(resolve));
+ });
+ }
+ assert.fail(
+ `Timed out waiting for the Side Conversation state${diagnostics ? ` (${diagnostics()})` : ''}`,
+ );
+}
diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts
index 941b9d72e4..887a852605 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts
@@ -257,6 +257,7 @@ test('drives the renderer Session catalog facade through real UDS framing', asyn
completeComputerUseTurn() {},
createSessionCopyCleanup: () => ({
ownCreation: (_creation, operation) => operation(),
+ rejectCreation: async () => undefined,
cleanup: async () => undefined,
schedule: async () => undefined,
abandonOwner: async () => undefined,
@@ -571,6 +572,7 @@ function ipcHarness() {
function unusedSessionCopyCleanup() {
return {
ownCreation: async (_creation: unknown, operation: () => Promise) => operation(),
+ async rejectCreation() {},
async cleanup() {},
async schedule() {},
async abandonOwner() {},
diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts
index a576c9c170..ea2d4b24b7 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts
@@ -524,6 +524,7 @@ test('does not release or report a Revision the Host retained during cleanup', a
removeSessionCopy = removeSession;
return {
ownCreation: (_creation, operation) => operation(),
+ rejectCreation: async () => undefined,
cleanup: async () => undefined,
schedule: async () => undefined,
abandonOwner: async () => undefined,
@@ -864,6 +865,7 @@ function deps(
completeComputerUseTurn() {},
createSessionCopyCleanup: () => ({
ownCreation: (_creation, operation) => operation(),
+ rejectCreation: async () => undefined,
cleanup: async () => undefined,
schedule: async () => undefined,
abandonOwner: async () => undefined,
diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-catalog-running-turns.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-catalog-running-turns.test.ts
index 7d2fca2de3..cba9d1a395 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-session-catalog-running-turns.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-session-catalog-running-turns.test.ts
@@ -36,6 +36,7 @@ test('projects observed running Turn identities into renderer Session lists', as
emitSessionsChanged() {},
releaseSessionResources() {},
sessionCopyCleanup: {
+ async rejectCreation() {},
recover: async () => ({ failed: [] }),
} as never,
},
@@ -76,6 +77,7 @@ test('merges catalog and observed running Turn identities in stable order', asyn
emitSessionsChanged() {},
releaseSessionResources() {},
sessionCopyCleanup: {
+ async rejectCreation() {},
recover: async () => ({ failed: [] }),
} as never,
},
diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts
index b03be46a23..a59a82cf65 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts
@@ -201,13 +201,15 @@ test("retries committed Branch and Revision copies with the renderer-owned ident
assert.equal(committed.size, 3);
});
-test("marks Runtime Host Branch copies as side conversations", async () => {
+test("sends Side Conversation intent and metadata atomically to Runtime Host", async () => {
+ const copyInputs: unknown[] = [];
const metadataUpdates: unknown[] = [];
const abandonedOwners: string[] = [];
const backgroundErrors: unknown[] = [];
const ipc = ipcHarness();
const sessionCopyCleanup = {
ownCreation: (_creation: unknown, operation: () => Promise) => operation(),
+ async rejectCreation() {},
async cleanup() {},
async schedule() {},
async abandonOwner(ownerId: string) {
@@ -221,17 +223,20 @@ test("marks Runtime Host Branch copies as side conversations", async () => {
registerExecutionIpc(
{
client: executionClient({
- copySession: async (_kind, input) => ({
- ...session(),
- id: input.targetSessionId,
- labels: ["source-label"],
- }),
+ copySession: async (_kind, input) => {
+ copyInputs.push(input);
+ return {
+ ...session(),
+ id: input.targetSessionId,
+ labels: ["source-label", SIDE_CONVERSATION_SESSION_LABEL],
+ };
+ },
updateSessionMetadata: async (sessionId, patch) => {
metadataUpdates.push({ sessionId, patch });
return {
...session(),
id: sessionId,
- labels: patch.labels ?? [],
+ labels: patch.labels ?? ['source-label', SIDE_CONVERSATION_SESSION_LABEL],
};
},
}),
@@ -247,23 +252,26 @@ test("marks Runtime Host Branch copies as side conversations", async () => {
ipc,
);
- const branch = (await ipc.invoke("sessions:branchFromTurn", "source-session", {
+ const branchResult = (await ipc.invoke("sessions:branchFromTurn", "source-session", {
sourceTurnId: "source-turn",
copyId: "side-copy",
name: "Side chat",
sideConversation: true,
- })) as { labels: string[] };
+ })) as { ok: true; session: { labels: string[] } };
- assert.deepEqual(metadataUpdates, [
+ assert.deepEqual(copyInputs, [
{
- sessionId: "side-copy",
- patch: {
- name: "Side chat",
- labels: ["source-label", SIDE_CONVERSATION_SESSION_LABEL],
- },
+ sourceSessionId: 'source-session',
+ targetSessionId: 'side-copy',
+ sourceTurnId: 'source-turn',
+ intent: 'side_conversation',
},
]);
- assert.deepEqual(branch.labels, [
+ assert.deepEqual(metadataUpdates, [
+ { sessionId: 'side-copy', patch: { name: 'Side chat' } },
+ ]);
+ assert.equal(branchResult.ok, true);
+ assert.deepEqual(branchResult.session.labels, [
"source-label",
SIDE_CONVERSATION_SESSION_LABEL,
]);
@@ -276,6 +284,50 @@ test("marks Runtime Host Branch copies as side conversations", async () => {
]);
});
+test('returns structured Side Conversation setup failures across IPC', async () => {
+ for (const reason of ['session_busy', 'operation_unavailable'] as const) {
+ const ipc = ipcHarness();
+ const rejectedCreations: string[] = [];
+ registerExecutionIpc(
+ {
+ client: executionClient({
+ copySession: async () => {
+ throw new RuntimeHostOperationError(
+ 'session.branch.create',
+ reason,
+ 'Side Conversation setup failed',
+ );
+ },
+ }),
+ observer: unusedObserver(),
+ attachmentApprovals: createAttachmentApprovalRegistry(),
+ emitSessionsChanged() {},
+ stat: async () => ({ size: 0 }),
+ resizeImage: async (bytes) => bytes,
+ beforeStop() {},
+ newId: () => 'id-1',
+ sessionCopyCleanup: {
+ ...unusedSessionCopyCleanup(),
+ async rejectCreation(sessionId) {
+ rejectedCreations.push(sessionId);
+ },
+ },
+ },
+ ipc,
+ );
+
+ assert.deepEqual(
+ await ipc.invoke('sessions:branchFromTurn', 'source-session', {
+ sourceTurnId: 'source-turn',
+ copyId: `side-copy-${reason}`,
+ sideConversation: true,
+ }),
+ { ok: false, reason },
+ );
+ assert.deepEqual(rejectedCreations, [`side-copy-${reason}`]);
+ }
+});
+
test("sends canonical content and uploads owned Attachment bytes through the Host", async () => {
const starts: unknown[] = [];
const uploads: unknown[] = [];
@@ -1089,6 +1141,7 @@ function registerExecutionIpc(
function unusedSessionCopyCleanup(): RuntimeHostSessionExecutionIpcDeps['sessionCopyCleanup'] {
return {
ownCreation: async (_creation, operation) => operation(),
+ async rejectCreation() {},
async cleanup() {},
async schedule() {},
async abandonOwner() {},
diff --git a/apps/desktop/src/main/quote-companion-cleanup.ts b/apps/desktop/src/main/quote-companion-cleanup.ts
index 09d4576255..a5e2ed8952 100644
--- a/apps/desktop/src/main/quote-companion-cleanup.ts
+++ b/apps/desktop/src/main/quote-companion-cleanup.ts
@@ -25,6 +25,7 @@ export interface SessionCopyCreationLease {
kind: 'branch' | 'revision';
sourceSessionId: string;
sourceTurnId: string;
+ intent?: 'side_conversation';
ownerId: string;
}
@@ -61,6 +62,7 @@ export interface SessionCopyCleanupRecovery {
export interface SessionCopyCleanupAuthority {
ownCreation(creation: SessionCopyCreationLease, operation: () => Promise): Promise;
+ rejectCreation(sessionId: string): Promise;
cleanup(sessionId: string): Promise;
schedule(sessionId: string): Promise;
abandonOwner(ownerId: string): Promise;
@@ -125,6 +127,17 @@ class SessionCopyCleanupAuthorityImpl implements SessionCopyCleanupAuthority {
return task;
}
+ async rejectCreation(sessionId: string): Promise {
+ const normalized = normalizeSessionId(sessionId);
+ await this.creations.get(normalized)?.operation.catch(() => undefined);
+ const record = await this.store.read(normalized);
+ if (!record) return;
+ if (record.phase !== 'creating') {
+ throw new Error(`Session copy ${normalized} is no longer awaiting creation`);
+ }
+ await this.store.forget(normalized);
+ }
+
async cleanup(sessionId: string): Promise {
const normalized = normalizeSessionId(sessionId);
const active = this.cleanups.get(normalized);
@@ -258,6 +271,7 @@ class SqliteSessionCopyCleanupStore implements SessionCopyCleanupStore {
kind: creation.kind,
sourceSessionId: creation.sourceSessionId,
sourceTurnId: creation.sourceTurnId,
+ ...(creation.intent ? { intent: creation.intent } : {}),
},
};
});
@@ -396,6 +410,7 @@ function normalizeCreationLease(creation: SessionCopyCreationLease): SessionCopy
kind: creation.kind,
sourceSessionId: normalizeSessionId(creation.sourceSessionId),
sourceTurnId: normalizeSessionId(creation.sourceTurnId),
+ ...(creation.intent === 'side_conversation' ? { intent: creation.intent } : {}),
ownerId: normalizeOwnerId(creation.ownerId),
};
}
@@ -409,6 +424,7 @@ function sameCreation(
left.kind === right.kind &&
left.sourceSessionId === right.sourceSessionId &&
left.sourceTurnId === right.sourceTurnId &&
+ left.intent === right.intent &&
left.ownerId === right.ownerId
);
}
@@ -420,7 +436,8 @@ function samePersistedCreation(
return (
left.kind === right.kind &&
left.sourceSessionId === right.sourceSessionId &&
- left.sourceTurnId === right.sourceTurnId
+ left.sourceTurnId === right.sourceTurnId &&
+ left.intent === right.intent
);
}
diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts
index 37396022b1..071e86bd8a 100644
--- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts
+++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts
@@ -130,6 +130,7 @@ export interface DesktopRuntimeHostCandidateDeps {
kind: 'branch' | 'revision';
sourceSessionId: string;
sourceTurnId: string;
+ intent?: 'side_conversation';
}) => Promise;
}) => SessionCopyCleanupAuthority;
readonly registerClientIpc?: (
@@ -638,11 +639,12 @@ export async function createDesktopRuntimeHostCandidate(
emitSessionsChanged("deleted", sessionId);
return disposition;
},
- resumeSessionCopy: async ({ sessionId, kind, sourceSessionId, sourceTurnId }) => {
+ resumeSessionCopy: async ({ sessionId, kind, sourceSessionId, sourceTurnId, intent }) => {
await client.copySession(kind, {
sourceSessionId,
targetSessionId: sessionId,
sourceTurnId,
+ ...(intent ? { intent } : {}),
});
},
});
diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts
index a6653fedb0..3be6608f6a 100644
--- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts
+++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts
@@ -26,7 +26,6 @@ import {
type SessionChangedEvent,
type SessionChangedReason,
} from '@maka/core/session';
-import { SIDE_CONVERSATION_SESSION_LABEL } from '@maka/core/side-conversation';
import { type ActiveInteractionRequestEvent, type AttachmentRef } from '@maka/core/events';
import { type PermissionMode } from '@maka/core/permission';
import { type SandboxBoundaryResponse } from '@maka/core/sandbox-boundary';
@@ -60,6 +59,10 @@ import type { DesktopTranscriptRangeRequest } from '../preload/transcript-contra
import { toDesktopHostSessionSummary } from "./runtime-host-session-catalog-ipc-main.js";
import { mergeWorkspaceFileInlineReferences } from "./session-workspace-inline-references.js";
+type SideConversationBranchResult =
+ | { readonly ok: true; readonly session: ReturnType }
+ | { readonly ok: false; readonly reason: 'session_busy' | 'operation_unavailable' };
+
type RuntimeHostSessionExecutionClient = Pick<
DesktopRuntimeHostClient,
| "answerInteraction"
@@ -624,36 +627,47 @@ export function registerRuntimeHostSessionExecutionIpc(
sourceSessionId: sessionId,
targetSessionId: normalized.copyId,
sourceTurnId: normalized.sourceTurnId,
+ ...(normalized.sideConversation ? { intent: 'side_conversation' as const } : {}),
});
- let branch = normalized.sideConversation
- ? await deps.sessionCopyCleanup.ownCreation(
- {
- sessionId: normalized.copyId,
- kind: 'branch',
- sourceSessionId: sessionId,
- sourceTurnId: normalized.sourceTurnId,
- ownerId: bindCopyOwner(event),
- },
- createBranch,
- )
- : await createBranch();
- if (normalized.name || normalized.sideConversation) {
+ let branch;
+ try {
+ branch = normalized.sideConversation
+ ? await deps.sessionCopyCleanup.ownCreation(
+ {
+ sessionId: normalized.copyId,
+ kind: 'branch',
+ sourceSessionId: sessionId,
+ sourceTurnId: normalized.sourceTurnId,
+ intent: 'side_conversation',
+ ownerId: bindCopyOwner(event),
+ },
+ createBranch,
+ )
+ : await createBranch();
+ } catch (error) {
+ if (
+ normalized.sideConversation &&
+ error instanceof RuntimeHostOperationError &&
+ (error.code === 'session_busy' || error.code === 'operation_unavailable')
+ ) {
+ await deps.sessionCopyCleanup.rejectCreation(normalized.copyId);
+ return {
+ ok: false,
+ reason: error.code,
+ } satisfies SideConversationBranchResult;
+ }
+ throw error;
+ }
+ if (normalized.name) {
branch = await deps.client.updateSessionMetadata(branch.id, {
- ...(normalized.name ? { name: normalized.name } : {}),
- ...(normalized.sideConversation
- ? {
- labels: [
- ...new Set([
- ...branch.labels,
- SIDE_CONVERSATION_SESSION_LABEL,
- ]),
- ],
- }
- : {}),
+ name: normalized.name,
});
}
deps.emitSessionsChanged("created", branch.id);
- return toDesktopHostSessionSummary(branch);
+ const summary = toDesktopHostSessionSummary(branch);
+ return normalized.sideConversation
+ ? ({ ok: true, session: summary } satisfies SideConversationBranchResult)
+ : summary;
},
);
ipcMain.handle(
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index f05bdac90d..01fc780311 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -200,6 +200,10 @@ export type DesktopBranchFromTurnInput = BranchFromTurnInput & {
copyId: string;
};
+export type DesktopSideConversationBranchResult =
+ | { ok: true; session: DesktopSessionSummary }
+ | { ok: false; reason: 'session_busy' | 'operation_unavailable' };
+
export type DesktopReviseBeforeTurnInput = ReviseBeforeTurnInput & {
/** Stable target identity for retrying one Desktop copy action. */
copyId: string;
@@ -772,7 +776,14 @@ export interface MakaBridge {
| { disposition: 'park'; rejectionReasons: string[]; diagnostics: unknown[] }
>;
regenerateTurn(sessionId: string, input: RegenerateTurnInput): Promise;
- branchFromTurn(sessionId: string, input: DesktopBranchFromTurnInput): Promise;
+ branchFromTurn(
+ sessionId: string,
+ input: DesktopBranchFromTurnInput & { sideConversation: true },
+ ): Promise;
+ branchFromTurn(
+ sessionId: string,
+ input: DesktopBranchFromTurnInput & { sideConversation?: false },
+ ): Promise;
reviseBeforeTurn(sessionId: string, input: DesktopReviseBeforeTurnInput): Promise;
respondToSandboxBoundary(sessionId: string, response: SandboxBoundaryResponse): Promise;
respondToUserQuestion(sessionId: string, response: UserQuestionResponse): Promise;
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index 67b2c15bb7..ac25fc6ce0 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -30,6 +30,7 @@ import type {
PermissionOverlayStartResult,
RendererIngestInput,
DesktopBranchFromTurnInput,
+ DesktopSideConversationBranchResult,
DesktopReviseBeforeTurnInput,
AppUpdateInstallRequest,
AppUpdateInstallResult,
@@ -608,6 +609,34 @@ async function invokeSessionSummary(
return projectSessionSummary(session.scope, summary);
}
+async function invokeBranchFromTurn(
+ sessionId: string,
+ input: DesktopBranchFromTurnInput & { sideConversation: true },
+): Promise;
+async function invokeBranchFromTurn(
+ sessionId: string,
+ input: DesktopBranchFromTurnInput & { sideConversation?: false },
+): Promise;
+async function invokeBranchFromTurn(
+ sessionId: string,
+ input: DesktopBranchFromTurnInput,
+): Promise {
+ const ref = await runtimeHostSessionRef(sessionId);
+ const result = await ipcRenderer.invoke(
+ 'sessions:branchFromTurn',
+ ref.scope,
+ ref.sessionId,
+ input,
+ ) as SessionSummary | { ok: true; session: SessionSummary } | { ok: false; reason: string };
+ if (input.sideConversation) {
+ if (!('ok' in result) || result.ok === false) {
+ return result as DesktopSideConversationBranchResult;
+ }
+ return { ok: true, session: projectSessionSummary(ref.scope, result.session) };
+ }
+ return projectSessionSummary(ref.scope, result as SessionSummary);
+}
+
async function invokeSessionInput(
channel: string,
input: I,
@@ -1619,13 +1648,7 @@ const makaBridge = {
regenerateTurn(sessionId: string, input: RegenerateTurnInput): Promise {
return invokeSessionRuntimeHost('sessions:regenerateTurn', sessionId, input);
},
- async branchFromTurn(sessionId: string, input: DesktopBranchFromTurnInput): Promise {
- const ref = await runtimeHostSessionRef(sessionId);
- const summary = await ipcRenderer.invoke(
- 'sessions:branchFromTurn', ref.scope, ref.sessionId, input,
- ) as SessionSummary;
- return projectSessionSummary(ref.scope, summary);
- },
+ branchFromTurn: invokeBranchFromTurn,
async reviseBeforeTurn(sessionId: string, input: DesktopReviseBeforeTurnInput): Promise {
const ref = await runtimeHostSessionRef(sessionId);
const summary = await ipcRenderer.invoke(
diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts
index ca959adc65..fb14676742 100644
--- a/apps/desktop/src/renderer/features/workbar/ports.ts
+++ b/apps/desktop/src/renderer/features/workbar/ports.ts
@@ -36,6 +36,7 @@ import type { PermissionMode } from '@maka/core/permission';
import type { RegenerateTurnInput } from '@maka/core/runtime-inputs';
import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary';
import type {
+ SessionChangedEvent,
SessionSummary,
StoredMessage,
TurnRecord,
@@ -212,9 +213,12 @@ export interface SideChatSessionPort {
sourceTurnId: string;
name?: string;
copyId: string;
- sideConversation?: boolean;
+ sideConversation: true;
},
- ): Promise;
+ ): Promise<
+ | { ok: true; session: SessionSummary }
+ | { ok: false; reason: 'session_busy' | 'operation_unavailable' }
+ >;
cleanupSessionCopy(sessionId: string): Promise;
abandonSessionCopy(sourceSessionId: string, copyId: string): Promise;
send(
@@ -246,6 +250,7 @@ export interface SideChatSessionPort {
sessionId: string,
handler: (event: SessionEvent) => void,
): WorkbarUnsubscribe;
+ subscribeSessionChanges(handler: (event: SessionChangedEvent) => void): WorkbarUnsubscribe;
}
export interface WorkbarServices {
diff --git a/apps/desktop/src/renderer/features/workbar/testing.ts b/apps/desktop/src/renderer/features/workbar/testing.ts
index 576cd77fde..f2e3e940b5 100644
--- a/apps/desktop/src/renderer/features/workbar/testing.ts
+++ b/apps/desktop/src/renderer/features/workbar/testing.ts
@@ -37,6 +37,7 @@ export * from './tools/inspector/session-inspector-overview-model.js';
export * from './tools/side-chat/quote-companion-panel-state.js';
export * from './tools/side-chat/quote-companion-core.js';
export * from './tools/side-chat/quote-companion-visibility.js';
+export { useQuoteCompanion } from './tools/side-chat/use-quote-companion.js';
export * from './tools/terminal/session-terminal-hydration.js';
export * from './tools/terminal/session-terminal-query.js';
export * from './tools/terminal/session-terminal-frame.js';
@@ -136,6 +137,7 @@ export function createFakeWorkbarServices(
respondToSandboxBoundary: async () => undefined,
respondToUserQuestion: async () => undefined,
subscribeEvents: noopSubscription,
+ subscribeSessionChanges: noopSubscription,
},
...overrides,
};
diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts
index ec5897b7cc..dceb846301 100644
--- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts
+++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts
@@ -50,6 +50,8 @@ import { sessionEventErrorMessage } from '../../../../model-connection-errors.js
* `{ok:false}`. */
export type CompanionErrorCode =
| 'fork_setup_failed'
+ | 'fork_source_busy'
+ | 'fork_unsupported'
| 'send_failed'
| 'send_rejected';
@@ -247,12 +249,20 @@ export async function ensureCompanionFork(
) {
return { status: 'error', code: 'fork_setup_failed' };
}
- created = await api.branchFromTurn(sourceSession.id, {
+ const result = await api.branchFromTurn(sourceSession.id, {
sourceTurnId: copyAttempt.sourceTurnId,
name,
copyId: copyAttempt.copyId,
sideConversation: true,
});
+ if (!result.ok) {
+ copyAttempt.complete();
+ return {
+ status: 'error',
+ code: result.reason === 'session_busy' ? 'fork_source_busy' : 'fork_unsupported',
+ };
+ }
+ created = result.session;
} catch {
return { status: 'error', code: 'fork_setup_failed' };
}
diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts
index 8ba10b32bb..7c4bcb5be8 100644
--- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts
+++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts
@@ -51,15 +51,15 @@ import {
performCompanionTurn,
type CompanionErrorCode,
type EnsureCompanionForkResult,
-} from './quote-companion-core';
+} from './quote-companion-core.js';
import { mergeSettledMessages } from '../../../../settled-message-merge.js';
import { getDesktopConversationCopy } from '../../../../locales/conversation-copy.js';
import {
snapshotCompanionQuotes,
type CompanionQuoteSnapshot,
type StagedCompanionQuote,
-} from './quote-companion-panel-state';
-import type { CompanionForkVisibilityEvent } from './quote-companion-visibility';
+} from './quote-companion-panel-state.js';
+import type { CompanionForkVisibilityEvent } from './quote-companion-visibility.js';
export interface UseQuoteCompanionInput {
/** Stable owner for the currently mounted panel generation. */
@@ -151,8 +151,11 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan
// A created fork is hidden immediately, but is not considered usable until
// onForkCommitted promotes it.
const pendingForkIdRef = useRef(null);
+ const sourceSessionRef = useRef(sourceSession);
+ sourceSessionRef.current = sourceSession;
+ const sourceSessionId = sourceSession?.id;
const sourceSessionIdRef = useRef(sourceSession?.id);
- sourceSessionIdRef.current = sourceSession?.id;
+ sourceSessionIdRef.current = sourceSessionId;
const forkSetupPromiseRef = useRef | null>(null);
const stopRequestedRef = useRef(false);
const activeTurnIdRef = useRef(null);
@@ -178,6 +181,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan
);
const [hasContent, setHasContent] = useState(false);
const [error, setError] = useState(null);
+ const [forkRetryPending, setForkRetryPending] = useState(false);
// Bumped whenever the own-turn set changes so the render picks up the new
// filter result (the set lives in a ref to stay stable for the event handler).
const [, setOwnTurnTick] = useState(0);
@@ -264,18 +268,23 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan
);
const ensureFork = useCallback(
- (name: string): Promise => {
+ (
+ name: string,
+ options: { readonly showPreparing?: boolean } = {},
+ ): Promise => {
const existing = companionRef.current;
if (existing) return Promise.resolve({ status: 'ready', session: existing });
if (forkSetupPromiseRef.current) return forkSetupPromiseRef.current;
- if (!sourceSession) {
+ const currentSourceSession = sourceSessionRef.current;
+ if (!currentSourceSession) {
return Promise.resolve({ status: 'error', code: 'fork_setup_failed' });
}
- setPreparing(true);
+ const showPreparing = options.showPreparing ?? true;
+ if (showPreparing) setPreparing(true);
const promise = ensureCompanionFork({
api: sideChat,
- sourceSession,
+ sourceSession: currentSourceSession,
panelId,
name,
isDisposed: () => !mountedRef.current,
@@ -298,25 +307,67 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan
})
.then((result) => {
if (result.status === 'ready' && mountedRef.current) {
+ setForkRetryPending(false);
+ setError(null);
commitFork(result.session);
} else if (result.status === 'error' && mountedRef.current) {
- setError(copyRef.current.errors.forkSetupFailed);
+ setForkRetryPending(result.code === 'fork_source_busy');
+ const errors = copyRef.current.errors;
+ setError(
+ result.code === 'fork_source_busy'
+ ? errors.forkSourceBusy
+ : result.code === 'fork_unsupported'
+ ? errors.forkUnsupported
+ : errors.forkSetupFailed,
+ );
}
return result;
})
.finally(() => {
forkSetupPromiseRef.current = null;
- if (mountedRef.current) setPreparing(false);
+ if (showPreparing && mountedRef.current) setPreparing(false);
});
forkSetupPromiseRef.current = promise;
return promise;
},
- [commitFork, mountedRef, panelId, sideChat, sourceSession],
+ [commitFork, mountedRef, panelId, sideChat],
);
useEffect(() => {
- if (sourceSession) void ensureFork(copyRef.current.defaultName);
- }, [ensureFork, sourceSession]);
+ if (sourceSessionId) void ensureFork(copyRef.current.defaultName);
+ }, [ensureFork, sourceSessionId]);
+
+ useEffect(() => {
+ if (!sourceSessionId || !forkRetryPending) return;
+ let retrying = false;
+ const retry = () => {
+ if (retrying || !mountedRef.current || companionRef.current) return;
+ retrying = true;
+ const currentSetup = forkSetupPromiseRef.current;
+ void (async () => {
+ if (currentSetup) await currentSetup;
+ if (!mountedRef.current || companionRef.current) return;
+ await ensureFork(copyRef.current.defaultName, { showPreparing: false });
+ })().finally(() => {
+ retrying = false;
+ });
+ };
+ const unsubscribe = sideChat.subscribeSessionChanges((event) => {
+ if (
+ event.sessionId === sourceSessionId &&
+ (event.reason === 'turn-status-change' ||
+ event.reason === 'status-change' ||
+ event.reason === 'message-appended')
+ ) {
+ retry();
+ }
+ });
+ const retryTimer = globalThis.setInterval(retry, 2_000);
+ return () => {
+ globalThis.clearInterval(retryTimer);
+ unsubscribe();
+ };
+ }, [ensureFork, forkRetryPending, mountedRef, sideChat, sourceSessionId]);
// The fork is ephemeral (用完即弃): when the panel is dismissed — 退出,
// switching source session — unsubscribe and remove the fork so it never
@@ -427,6 +478,8 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan
const errors = copyRef.current.errors;
const byCode: Record = {
fork_setup_failed: errors.forkSetupFailed,
+ fork_source_busy: errors.forkSourceBusy,
+ fork_unsupported: errors.forkUnsupported,
send_failed: errors.sendFailed,
send_rejected: errors.sendRejected,
};
diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts
index 9fc6c9f9a8..f4ad6a5e3e 100644
--- a/apps/desktop/src/renderer/locales/conversation-copy.ts
+++ b/apps/desktop/src/renderer/locales/conversation-copy.ts
@@ -280,6 +280,10 @@ export interface DesktopConversationCopy {
errors: {
/** Reading the source boundary or creating the companion fork failed. */
forkSetupFailed: string;
+ /** The source or one of its linked child runs is still active. */
+ forkSourceBusy: string;
+ /** The retained source context cannot be represented safely. */
+ forkUnsupported: string;
/** `sessions.send` was rejected without throwing (e.g. an unresolved skill). */
sendRejected: string;
/** `sessions.send` threw / the turn could not be started. */
@@ -558,6 +562,8 @@ const COPY = {
},
errors: {
forkSetupFailed: '无法创建侧边对话,请稍后重试。',
+ forkSourceBusy: '主对话或子任务仍在运行,请等待完成后重试。',
+ forkUnsupported: '当前对话上下文暂不支持创建侧边对话。',
sendRejected: '追问未能开始,请稍后重试。',
sendFailed: '追问失败,请稍后重试。',
settlementFailed: '运行已结束,但消息加载失败。请重试或重新打开侧边对话。',
@@ -761,6 +767,9 @@ const COPY = {
},
errors: {
forkSetupFailed: 'Could not open the side chat. Please try again.',
+ forkSourceBusy:
+ 'The main conversation or a linked task is still running. Try again when it finishes.',
+ forkUnsupported: 'This conversation context cannot be opened as a side chat yet.',
sendRejected: 'The companion could not start. Please try again.',
sendFailed: 'The companion request failed. Please try again.',
settlementFailed: 'The run ended, but its messages could not be loaded. Retry or reopen the side chat.',
diff --git a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts
index 3f22d6e2c9..86c9d0ef00 100644
--- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts
+++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts
@@ -132,6 +132,7 @@ export function createDesktopWorkbarServices(
bridge.sessions.respondToUserQuestion(sessionId, response),
subscribeEvents: (sessionId, handler) =>
bridge.sessions.subscribeEvents(sessionId, handler),
+ subscribeSessionChanges: (handler) => bridge.sessions.subscribeChanges(handler),
},
};
}
diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx
index f2f6d14fd6..600fd6ebfa 100644
--- a/apps/desktop/stories/session-workbar.stories.tsx
+++ b/apps/desktop/stories/session-workbar.stories.tsx
@@ -706,7 +706,7 @@ function bridge(options: {
},
],
readSettledMessages: async () => ({ messages: [], settled: true }),
- branchFromTurn: async () => SIDE_CHAT_SESSION,
+ branchFromTurn: async () => ({ ok: true, session: SIDE_CHAT_SESSION }),
cleanupSessionCopy: async () => undefined,
abandonSessionCopy: async () => undefined,
send: async () => ({ ok: true }),
@@ -720,6 +720,7 @@ function bridge(options: {
respondToSandboxBoundary: async () => undefined,
respondToUserQuestion: async () => undefined,
subscribeEvents: unsubscribe,
+ subscribeSessionChanges: unsubscribe,
},
});
return (Story) => (
diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts
index 48b545754f..b1dd1e694c 100644
--- a/packages/core/src/session.ts
+++ b/packages/core/src/session.ts
@@ -158,6 +158,7 @@ export interface SessionConversationCopy {
sourceTurnId: string;
requestFingerprint: `sha256:${string}`;
state: 'preparing' | 'committed';
+ intent?: 'side_conversation';
}
export type SubagentSessionRuntimeSummary = Omit<
@@ -452,7 +453,7 @@ const SUBAGENT_SESSION_SPAWN_IDENTITY_SHAPE = defineObjectShape()(
['kind', 'sourceSessionId', 'sourceTurnId', 'requestFingerprint', 'state'],
- [],
+ ['intent'],
);
const SESSION_LINEAGE_ID_MAX_CHARS = 512;
const SESSION_LINEAGE_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
@@ -551,6 +552,8 @@ export function isSessionConversationCopy(value: unknown): value is SessionConve
isRecord(value) &&
hasExactShape(value, SESSION_CONVERSATION_COPY_SHAPE) &&
(value.kind === 'branch' || value.kind === 'revision') &&
+ (value.intent === undefined ||
+ (value.kind === 'branch' && value.intent === 'side_conversation')) &&
isSessionLineageId(value.sourceSessionId) &&
isSessionLineageId(value.sourceTurnId) &&
typeof value.requestFingerprint === 'string' &&
diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts
index 6726905f71..c4f2fad83b 100644
--- a/packages/runtime-host/src/__tests__/protocol.test.ts
+++ b/packages/runtime-host/src/__tests__/protocol.test.ts
@@ -182,6 +182,13 @@ describe('Runtime Host bootstrap protocol', () => {
assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 46);
});
+ test('publishes a new compatibility epoch for Side Conversation copy intent', () => {
+ // Epoch 47 belongs to project registration preferences on current main.
+ // Side Conversation adds another closed branch-copy input and therefore
+ // needs its own later handshake boundary.
+ assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 47);
+ });
+
test('adds credential rotation without changing existing credential inputs', () => {
const issueInput = {
principalKind: 'remote_owner',
diff --git a/packages/runtime-host/src/__tests__/session-revision-graph-references.test.ts b/packages/runtime-host/src/__tests__/session-revision-graph-references.test.ts
index fa4aab83e2..1c61f354ba 100644
--- a/packages/runtime-host/src/__tests__/session-revision-graph-references.test.ts
+++ b/packages/runtime-host/src/__tests__/session-revision-graph-references.test.ts
@@ -72,6 +72,95 @@ test('Agent Graph revision references preserve only exact terminal provenance',
assert.equal(archived.ok, true);
});
+test('Side Conversation references accept terminal linked children as snapshots', async () => {
+ const accepted = await prepare({ kind: 'side_conversation' });
+ assert.equal(accepted.ok, true);
+ if (!accepted.ok) assert.fail('Expected accepted Side Conversation references');
+ assert.deepEqual([...accepted.references.keys()], [CHILD_SESSION_ID]);
+});
+
+test('Side Conversation references accept terminal non-Graph child Sessions as snapshots', async () => {
+ const accepted = await prepare({
+ kind: 'side_conversation',
+ messages: [linkedSubagentResult('completed')],
+ sessionHeaders: [sessionHeader(ROOT_SESSION_ID), childHeader({ graph: false })],
+ });
+ assert.equal(accepted.ok, true);
+ if (!accepted.ok) assert.fail('Expected accepted linked-child snapshot');
+ assert.deepEqual([...accepted.references.keys()], [CHILD_SESSION_ID]);
+});
+
+test('Side Conversation references wait for live Graph and child state', async () => {
+ for (const input of [
+ { graphState: 'live' as const },
+ { childActive: true },
+ { messages: [linkedSubagentResult('running')] },
+ ]) {
+ const outcome = await prepare({ kind: 'side_conversation', ...input });
+ assert.equal(outcome.ok, false);
+ if (!outcome.ok) assert.equal(outcome.code, 'session_busy');
+ }
+});
+
+test('Side Conversation validates the retained Graph instead of a newer live Graph', async () => {
+ const sideConversation = await prepare({
+ kind: 'side_conversation',
+ sessionGraphState: 'live',
+ graphState: 'terminal',
+ });
+ assert.equal(sideConversation.ok, true);
+
+ const revision = await prepare({
+ sessionGraphState: 'live',
+ graphState: 'terminal',
+ });
+ assert.deepEqual(revision, {
+ ok: false,
+ code: 'session_busy',
+ message: 'A retained Agent Graph is not terminal',
+ });
+});
+
+test('Side Conversation rejects a retained child without a terminal result snapshot', async () => {
+ const outcome = await prepare({
+ kind: 'side_conversation',
+ messages: [],
+ sessionHeaders: [sessionHeader(ROOT_SESSION_ID), childHeader({ graph: false })],
+ });
+ assert.deepEqual(outcome, {
+ ok: false,
+ code: 'operation_unavailable',
+ message: 'Side Conversation requires a terminal result for every retained linked child',
+ });
+});
+
+test('Side Conversation waits for a live retained child before its result is committed', async () => {
+ const outcome = await prepare({
+ kind: 'side_conversation',
+ messages: [],
+ childActive: true,
+ sessionHeaders: [sessionHeader(ROOT_SESSION_ID), childHeader({ graph: false })],
+ });
+ assert.deepEqual(outcome, {
+ ok: false,
+ code: 'session_busy',
+ message: 'A retained linked child is still active',
+ });
+});
+
+test('Side Conversation waits for a live retained Graph before its result is committed', async () => {
+ const outcome = await prepare({
+ kind: 'side_conversation',
+ messages: [],
+ graphState: 'live',
+ });
+ assert.deepEqual(outcome, {
+ ok: false,
+ code: 'session_busy',
+ message: 'A retained Agent Graph is not terminal',
+ });
+});
+
test('Agent Graph revision references reject incomplete or mismatched provenance', async () => {
const cases: ReadonlyArray<{
name: string;
@@ -228,11 +317,12 @@ test('Agent Graph revision admission includes only retained direct and reference
});
interface PrepareOverrides {
- readonly kind?: 'branch' | 'revision';
+ readonly kind?: 'branch' | 'revision' | 'side_conversation';
readonly messages?: readonly StoredMessage[];
readonly archivedResults?: readonly string[];
readonly sessionHeaders?: readonly SessionHeader[];
readonly runs?: readonly AgentRunHeader[];
+ readonly sessionGraphState?: 'absent' | 'live' | 'terminal';
readonly graphState?: 'absent' | 'live' | 'terminal';
readonly artifactTurnId?: string;
readonly artifactStatus?: 'live' | 'deleted';
@@ -279,7 +369,8 @@ async function prepare(overrides: PrepareOverrides = {}) {
}),
},
graph: {
- readSessionState: async () => overrides.graphState ?? 'terminal',
+ readSessionState: async () =>
+ overrides.sessionGraphState ?? overrides.graphState ?? 'terminal',
readGraphState: async (_rootSessionId, graphId) => {
if (graphId !== agentGraphIdForRootSession(ROOT_SESSION_ID)) {
throw new Error('Graph is not bound to this root Session');
diff --git a/packages/runtime-host/src/__tests__/session-revision-protocol.test.ts b/packages/runtime-host/src/__tests__/session-revision-protocol.test.ts
index da25a96a24..46c50e0439 100644
--- a/packages/runtime-host/src/__tests__/session-revision-protocol.test.ts
+++ b/packages/runtime-host/src/__tests__/session-revision-protocol.test.ts
@@ -28,6 +28,63 @@ import {
} from '../protocol/index.js';
describe('Session revision protocol', () => {
+ test('accepts only the Side Conversation branch intent', () => {
+ assert.deepEqual(
+ decodeClientFrame({
+ requestId: 'request-side-conversation',
+ operation: 'session.branch.create',
+ input: {
+ sourceSessionId: 'source-session',
+ targetSessionId: 'target-session',
+ sourceTurnId: 'turn-1',
+ expectedSourceRevision: 1,
+ intent: 'side_conversation',
+ },
+ }),
+ {
+ requestId: 'request-side-conversation',
+ operation: 'session.branch.create',
+ input: {
+ sourceSessionId: 'source-session',
+ targetSessionId: 'target-session',
+ sourceTurnId: 'turn-1',
+ expectedSourceRevision: 1,
+ intent: 'side_conversation',
+ },
+ },
+ );
+ assert.throws(
+ () =>
+ decodeClientFrame({
+ requestId: 'request-invalid-purpose',
+ operation: 'session.branch.create',
+ input: {
+ sourceSessionId: 'source-session',
+ targetSessionId: 'target-session',
+ sourceTurnId: 'turn-1',
+ expectedSourceRevision: 1,
+ intent: 'ordinary',
+ },
+ }),
+ isInvalidFrame,
+ );
+ assert.throws(
+ () =>
+ decodeClientFrame({
+ requestId: 'request-revision-purpose',
+ operation: 'session.revision.create',
+ input: {
+ sourceSessionId: 'source-session',
+ targetSessionId: 'target-session',
+ sourceTurnId: 'turn-1',
+ expectedSourceRevision: 1,
+ intent: 'side_conversation',
+ },
+ }),
+ isInvalidFrame,
+ );
+ });
+
test('rejects aliasing, unknown fields, and mismatched response identities', () => {
assert.throws(
() =>
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 20a7ecafa1..5ccf51c26a 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
@@ -63,6 +63,10 @@ const ADMITTED_REVISION_TARGET_ID = 'admitted-revision-target';
const LINEAGE_REVISION_TARGET_ID = 'lineage-revision-target';
const LINEAGE_BRANCH_TARGET_ID = 'lineage-branch-target';
const GRAPH_REVISION_TARGET_ID = 'graph-revision-target';
+const GRAPH_SIDE_CONVERSATION_TARGET_ID = 'graph-side-conversation-target';
+const GRAPH_SIDE_CONVERSATION_REMOVAL_TARGET_ID = 'graph-side-conversation-removal-target';
+const ARCHIVED_SIDE_CONVERSATION_TARGET_ID = 'archived-side-conversation-target';
+const ACTIVE_SOURCE_SIDE_CONVERSATION_TARGET_ID = 'active-source-side-conversation-target';
test('two Clients share exact retryable Session branch and revision authority', {
skip: process.platform === 'win32' ? 'Windows SQLite shutdown lifecycle' : false,
@@ -116,6 +120,9 @@ test('two Clients share exact retryable Session branch and revision authority',
LINEAGE_REVISION_TARGET_ID,
LINEAGE_BRANCH_TARGET_ID,
GRAPH_REVISION_TARGET_ID,
+ GRAPH_SIDE_CONVERSATION_TARGET_ID,
+ ARCHIVED_SIDE_CONVERSATION_TARGET_ID,
+ ACTIVE_SOURCE_SIDE_CONVERSATION_TARGET_ID,
graphChildSessionId,
);
} finally {
@@ -178,6 +185,50 @@ async function verifyConcurrentRevisionAuthority(
}),
{ kind: 'session', session: null },
);
+ const sideConversation = await desktop.request('session.branch.create', {
+ sourceSessionId: linkedChildSourceSessionId,
+ targetSessionId: GRAPH_SIDE_CONVERSATION_TARGET_ID,
+ sourceTurnId: 'linked-turn',
+ expectedSourceRevision: linkedChildSource.revision,
+ intent: 'side_conversation',
+ });
+ assert.equal(sideConversation.kind, 'committed');
+ if (sideConversation.kind !== 'committed') {
+ assert.fail('Side Conversation must commit');
+ }
+ const sideConversationSession = requireSessionProjection(sideConversation.session);
+ assert.ok(sideConversationSession.labels.includes('mode:side_conversation'));
+ await assert.rejects(
+ desktop.request('session.branch.create', {
+ sourceSessionId: linkedChildSourceSessionId,
+ targetSessionId: GRAPH_SIDE_CONVERSATION_TARGET_ID,
+ sourceTurnId: 'linked-turn',
+ expectedSourceRevision: linkedChildSource.revision,
+ }),
+ operationError('operation_conflict'),
+ );
+ const removableSideConversation = await desktop.request('session.branch.create', {
+ sourceSessionId: linkedChildSourceSessionId,
+ targetSessionId: GRAPH_SIDE_CONVERSATION_REMOVAL_TARGET_ID,
+ sourceTurnId: 'linked-turn',
+ expectedSourceRevision: linkedChildSource.revision,
+ intent: 'side_conversation',
+ });
+ assert.equal(removableSideConversation.kind, 'committed');
+ if (removableSideConversation.kind !== 'committed') {
+ assert.fail('Removable Side Conversation must commit');
+ }
+ const removableSideConversationSession = requireSessionProjection(
+ removableSideConversation.session,
+ );
+ assert.deepEqual(
+ await desktop.request('session.remove', {
+ sessionId: GRAPH_SIDE_CONVERSATION_REMOVAL_TARGET_ID,
+ expectedRevision: removableSideConversationSession.revision,
+ }),
+ { kind: 'removed', sessionId: GRAPH_SIDE_CONVERSATION_REMOVAL_TARGET_ID },
+ );
+ assert.equal((await querySession(tui, graphChildSessionId)).id, graphChildSessionId);
const graphRevision = await desktop.request('session.revision.create', {
sourceSessionId: linkedChildSourceSessionId,
targetSessionId: GRAPH_REVISION_TARGET_ID,
@@ -240,6 +291,14 @@ async function verifyConcurrentRevisionAuthority(
}),
operationError('operation_unavailable'),
);
+ const archivedSideConversation = await desktop.request('session.branch.create', {
+ sourceSessionId: archivedOwnedSourceSessionId,
+ targetSessionId: ARCHIVED_SIDE_CONVERSATION_TARGET_ID,
+ sourceTurnId: 'archived-owned-turn',
+ expectedSourceRevision: archivedOwnedSource.revision,
+ intent: 'side_conversation',
+ });
+ assert.equal(archivedSideConversation.kind, 'committed');
for (const sessionId of ['metadata-linked-copy-target', 'archived-owned-copy-target']) {
assert.deepEqual(
await tui.request('session.catalog.query', {
@@ -409,6 +468,66 @@ async function verifyConcurrentRevisionAuthority(
throw cleanupError;
}
if (assertionError !== undefined) throw assertionError;
+
+ const activeSourceTurn = requireStartedTurn(
+ await desktop.startTurn({
+ sessionId: sourceSessionId,
+ turnId: 'active-source-turn',
+ content: { text: FAKE_ASK_USER_QUESTION_PROMPT },
+ }),
+ );
+ assertionError = undefined;
+ try {
+ const activeSource = await querySession(desktop, sourceSessionId);
+ const historicalCopyInput = {
+ sourceSessionId,
+ sourceTurnId: 'turn-2',
+ expectedSourceRevision: activeSource.revision,
+ };
+ await assert.rejects(
+ tui.request('session.branch.create', {
+ ...historicalCopyInput,
+ targetSessionId: 'active-source-ordinary-branch-target',
+ }),
+ operationError('session_busy'),
+ );
+ const sideConversation = await tui.request('session.branch.create', {
+ ...historicalCopyInput,
+ targetSessionId: ACTIVE_SOURCE_SIDE_CONVERSATION_TARGET_ID,
+ intent: 'side_conversation',
+ });
+ assert.equal(sideConversation.kind, 'committed');
+ if (sideConversation.kind !== 'committed') {
+ assert.fail('Side Conversation must fork a settled Turn while the source keeps running');
+ }
+ assert.ok(
+ requireSessionProjection(sideConversation.session).labels.includes(
+ 'mode:side_conversation',
+ ),
+ );
+ } catch (error) {
+ assertionError = error;
+ }
+ try {
+ const stopped = await desktop.stopTurn(
+ {
+ sessionId: sourceSessionId,
+ turnId: 'active-source-turn',
+ runId: activeSourceTurn.runId,
+ },
+ PROCESS_TIMEOUT_MS,
+ );
+ assert.equal(stopped.status, 'cancelled');
+ } catch (cleanupError) {
+ if (assertionError !== undefined) {
+ throw new AggregateError(
+ [assertionError, cleanupError],
+ 'active-source Side Conversation check failed and parked-turn cleanup failed',
+ );
+ }
+ throw cleanupError;
+ }
+ if (assertionError !== undefined) throw assertionError;
} finally {
await Promise.allSettled([desktop.close(), tui.close()]);
}
@@ -1357,6 +1476,9 @@ async function verifyDurableBranch(
lineageRevisionTargetId: string,
lineageBranchTargetId: string,
graphRevisionTargetId: string,
+ graphSideConversationTargetId: string,
+ archivedSideConversationTargetId: string,
+ activeSourceSideConversationTargetId: string,
graphChildSessionId: string,
): Promise {
const owner = await tryAcquireInteractiveRootOwner(capability);
@@ -1408,6 +1530,112 @@ async function verifyDurableBranch(
(await execution.sessionStore.readHeaderSnapshot(lineageBranchTargetId)).parentSessionId,
lineageRevisionTargetId,
);
+ const sideConversationHeader = await execution.sessionStore.readHeaderSnapshot(
+ graphSideConversationTargetId,
+ );
+ assert.equal(sideConversationHeader.conversationCopy?.intent, 'side_conversation');
+ assert.ok(sideConversationHeader.labels.includes('mode:side_conversation'));
+ const sideConversationMessages = await execution.sessionStore.readMessagesSnapshot(
+ graphSideConversationTargetId,
+ );
+ const sideConversationResult = sideConversationMessages.find(
+ (message) => message.type === 'tool_result' && message.content.kind === 'agent_swarm',
+ );
+ assert.ok(sideConversationResult?.type === 'tool_result');
+ if (
+ sideConversationResult?.type !== 'tool_result' ||
+ sideConversationResult.content.kind !== 'agent_swarm'
+ ) {
+ assert.fail('Side Conversation must retain the Agent Graph summary');
+ }
+ assert.equal(sideConversationResult.content.items[0]?.summary, 'done');
+ assert.equal(sideConversationResult.content.items[0]?.childSessionId, undefined);
+ assert.equal(sideConversationResult.content.items[0]?.runId, undefined);
+ const sideConversationArtifactId = sideConversationResult.content.items[0]?.artifactIds[0];
+ assert.ok(sideConversationArtifactId);
+ const activeSourceSideConversationMessages = await execution.sessionStore.readMessagesSnapshot(
+ activeSourceSideConversationTargetId,
+ );
+ assert.ok(activeSourceSideConversationMessages.some((message) => message.turnId === 'turn-2'));
+ assert.ok(
+ activeSourceSideConversationMessages.every(
+ (message) => message.turnId !== 'active-source-turn',
+ ),
+ );
+ const sideConversationRuns = await execution.agentRunStore.listSessionRuns(
+ graphSideConversationTargetId,
+ );
+ const sideConversationRun = sideConversationRuns.find((run) => run.turnId === 'linked-turn');
+ assert.ok(sideConversationRun);
+ const sideConversationRuntimeResult = (
+ await execution.runtimeEventStore.readRuntimeEvents(
+ graphSideConversationTargetId,
+ sideConversationRun.runId,
+ )
+ ).find((event) => event.content?.kind === 'function_response')?.content;
+ assert.ok(sideConversationRuntimeResult?.kind === 'function_response');
+ if (sideConversationRuntimeResult?.kind !== 'function_response') {
+ assert.fail('Side Conversation must retain its RuntimeEvent result snapshot');
+ }
+ const runtimeSideConversationResult = decodeCanonicalToolResultContent(
+ sideConversationRuntimeResult.result,
+ );
+ assert.equal(runtimeSideConversationResult.kind, 'agent_swarm');
+ if (runtimeSideConversationResult.kind !== 'agent_swarm') {
+ assert.fail('Copied RuntimeEvent result must remain an Agent Graph result');
+ }
+ assert.equal(runtimeSideConversationResult.items[0]?.childSessionId, undefined);
+ assert.equal(runtimeSideConversationResult.items[0]?.runId, undefined);
+ assert.deepEqual(runtimeSideConversationResult.items[0]?.artifactIds, [
+ sideConversationArtifactId,
+ ]);
+ assert.deepEqual(
+ await artifacts.readTextInSession(graphSideConversationTargetId, sideConversationArtifactId),
+ {
+ ok: true,
+ text: 'graph child result',
+ },
+ );
+ const archivedSideConversationRuns = await execution.agentRunStore.listSessionRuns(
+ archivedSideConversationTargetId,
+ );
+ const archivedSideConversationChildRun = archivedSideConversationRuns.find(
+ (run) => run.turnId === 'archived-owned-child-turn',
+ );
+ assert.ok(archivedSideConversationChildRun);
+ const archivedSideConversationResult = (
+ await execution.runtimeEventStore.readRuntimeEvents(
+ archivedSideConversationTargetId,
+ archivedSideConversationChildRun.runId,
+ )
+ ).find((event) => event.content?.kind === 'function_response')?.content;
+ assert.ok(archivedSideConversationResult?.kind === 'function_response');
+ if (archivedSideConversationResult?.kind !== 'function_response') {
+ assert.fail('Side Conversation must retain its archived tool result placeholder');
+ }
+ const archivedSideConversationContent = decodeCanonicalToolResultContent(
+ archivedSideConversationResult.result,
+ );
+ assert.equal(archivedSideConversationContent.kind, 'subagent');
+ if (archivedSideConversationContent.kind !== 'subagent') {
+ assert.fail('Copied archived child result must be restored as a static snapshot');
+ }
+ assert.notEqual(archivedSideConversationContent.runId, 'archived-owned-child-run');
+ assert.deepEqual(archivedSideConversationContent, {
+ kind: 'subagent',
+ agentName: 'Worker',
+ turnId: 'archived-owned-child-turn',
+ runId: archivedSideConversationChildRun.runId,
+ status: 'completed',
+ permissionMode: 'ask',
+ summary: 'done',
+ artifactIds: [],
+ });
+ const archivedSideConversationArtifacts = await artifacts.listPage(
+ archivedSideConversationTargetId,
+ { offset: 0, limit: 10 },
+ );
+ assert.equal(archivedSideConversationArtifacts.total, 0);
const graphRevisionMessages =
await execution.sessionStore.readMessagesSnapshot(graphRevisionTargetId);
const graphResult = graphRevisionMessages.find(
diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts
index 2d3569b957..19d5dd1f5b 100644
--- a/packages/runtime-host/src/protocol/index.ts
+++ b/packages/runtime-host/src/protocol/index.ts
@@ -91,7 +91,9 @@ 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 = 47 as const;
+export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 48 as const;
+// 48: Session branch creation accepts an explicit Side Conversation intent.
+// Older peers reject the strict input shape or cannot apply its snapshot semantics.
// 47: Project registration can carry an explicit location preference. Epoch-46
// hosts reject that optional field on the closed registration input.
// 46: Queued message content can be edited in place (queue.entry.update).
diff --git a/packages/runtime-host/src/protocol/session-revision.ts b/packages/runtime-host/src/protocol/session-revision.ts
index a61d995be9..c48f1fd533 100644
--- a/packages/runtime-host/src/protocol/session-revision.ts
+++ b/packages/runtime-host/src/protocol/session-revision.ts
@@ -17,7 +17,13 @@
* under the License.
*/
-import { requireCount, requireEntityId, requireExactRecord, requireRecord } from './codec.js';
+import {
+ requireCount,
+ requireEntityId,
+ requireExactRecord,
+ requireRecord,
+ requireShapedRecord,
+} from './codec.js';
import { invalidProtocolFrame } from './errors.js';
import { defineOperation } from './operation-spec.js';
import { decodeSessionCatalogItem, type SessionCatalogItem } from './session-catalog.js';
@@ -40,6 +46,7 @@ export interface SessionConversationCopyInput {
readonly targetSessionId: string;
readonly sourceTurnId: string;
readonly expectedSourceRevision: number;
+ readonly intent?: 'side_conversation';
}
export type SessionConversationCopyResult =
@@ -82,7 +89,7 @@ export const SESSION_REVISION_OPERATION_SPECS = {
mode: 'command',
availability: 'ready',
errors: SESSION_COPY_ERRORS,
- decodeInput: decodeSessionConversationCopyInput,
+ decodeInput: decodeSessionRevisionCopyInput,
decodeOutput: decodeSessionConversationCopyResult,
assertOutputForInput: assertConversationCopyOutput,
}),
@@ -104,6 +111,14 @@ export const SESSION_REVISION_OPERATION_SPECS = {
}),
} as const;
+function decodeSessionRevisionCopyInput(value: unknown): SessionConversationCopyInput {
+ const input = decodeSessionConversationCopyInput(value);
+ if (input.intent !== undefined) {
+ throw invalidProtocolFrame('Session revision copy does not support an intent');
+ }
+ return input;
+}
+
function decodeSessionRevisionAbandonInput(value: unknown): SessionRevisionAbandonInput {
const input = requireExactRecord(value, 'Session revision abandon input', ['targetSessionId']);
return { targetSessionId: requireEntityId(input.targetSessionId, 'targetSessionId') };
@@ -124,17 +139,20 @@ function decodeSessionRevisionAbandonResult(value: unknown): SessionRevisionAban
}
export function decodeSessionConversationCopyInput(value: unknown): SessionConversationCopyInput {
- const input = requireExactRecord(value, 'Session conversation-copy input', [
- 'sourceSessionId',
- 'targetSessionId',
- 'sourceTurnId',
- 'expectedSourceRevision',
- ]);
+ const input = requireShapedRecord(
+ value,
+ 'Session conversation-copy input',
+ ['sourceSessionId', 'targetSessionId', 'sourceTurnId', 'expectedSourceRevision'],
+ ['intent'],
+ );
const sourceSessionId = requireEntityId(input.sourceSessionId, 'sourceSessionId');
const targetSessionId = requireEntityId(input.targetSessionId, 'targetSessionId');
if (sourceSessionId === targetSessionId) {
throw invalidProtocolFrame('Session conversation copy requires distinct Sessions');
}
+ if (input.intent !== undefined && input.intent !== 'side_conversation') {
+ throw invalidProtocolFrame('Invalid Session conversation-copy intent');
+ }
return {
sourceSessionId,
targetSessionId,
@@ -143,6 +161,7 @@ export function decodeSessionConversationCopyInput(value: unknown): SessionConve
input.expectedSourceRevision,
'expected source Session revision',
),
+ ...(input.intent === 'side_conversation' ? { intent: input.intent } : {}),
};
}
diff --git a/packages/runtime-host/src/server/session-revision-coordinator.ts b/packages/runtime-host/src/server/session-revision-coordinator.ts
index be72f6fc29..4f3fc24538 100644
--- a/packages/runtime-host/src/server/session-revision-coordinator.ts
+++ b/packages/runtime-host/src/server/session-revision-coordinator.ts
@@ -19,6 +19,7 @@
import { createHash, randomUUID } from 'node:crypto';
import { isDeepResearchSession } from '@maka/core/explore-agent';
+import { SIDE_CONVERSATION_SESSION_LABEL } from '@maka/core/side-conversation';
import type { RuntimeEvent } from '@maka/core/runtime-event';
import type { CreateSessionInput } from '@maka/core/runtime-inputs';
import {
@@ -28,6 +29,7 @@ import {
type StoredMessage,
} from '@maka/core/session';
import {
+ archivedToolResultContainsLinkedChildReferences,
archivedToolResultContainsConversationOwnedReferences,
cloneConversationRuntimeLedger,
collectConversationCopyLinkedChildReferences,
@@ -71,6 +73,7 @@ import {
import { purgeSessionSidecars } from './session-sidecar-purge.js';
type ConversationCopyKind = 'branch' | 'revision';
+type ConversationCopySemanticKind = ConversationCopyKind | 'side_conversation';
type ConversationCopyOperationKey = Exclude<
SessionRevisionOperationKey,
'session.revision.abandon'
@@ -172,9 +175,10 @@ export class HostSessionRevisionCoordinator {
kind: ConversationCopyKind,
input: SessionConversationCopyInput,
): Promise {
- const requestFingerprint = conversationCopyFingerprint(kind, input);
+ const semanticKind = conversationCopySemanticKind(kind, input);
+ const requestFingerprint = conversationCopyFingerprint(semanticKind, input);
const retry = await this.options.admission.run(input.targetSessionId, async () =>
- this.#resolveExistingTarget(kind, input, requestFingerprint, true),
+ this.#resolveExistingTarget(semanticKind, input, requestFingerprint, true),
);
if (retry) return retry;
@@ -200,7 +204,7 @@ export class HostSessionRevisionCoordinator {
// lanes and keep the complete lease through validation and publication.
for (let pass = 0; pass < CONVERSATION_COPY_ADMISSION_PASSES; pass += 1) {
const result = await this.options.admission.runMany([...admittedSessionIds], (lease) =>
- this.#copyAdmitted(kind, input, requestFingerprint, lease, admittedSessionIds),
+ this.#copyAdmitted(semanticKind, input, requestFingerprint, lease, admittedSessionIds),
);
if ('ok' in result) return result;
for (const sessionId of result.sessionIds) admittedSessionIds.add(sessionId);
@@ -245,7 +249,7 @@ export class HostSessionRevisionCoordinator {
}
async #copyAdmitted(
- kind: ConversationCopyKind,
+ kind: ConversationCopySemanticKind,
input: SessionConversationCopyInput,
requestFingerprint: `sha256:${string}`,
lease: SessionAdmissionLease,
@@ -293,7 +297,7 @@ export class HostSessionRevisionCoordinator {
'Deep Research Sessions cannot be copied without an exact research ledger boundary',
);
}
- if (this.options.isSessionActive(input.sourceSessionId)) {
+ if (kind !== 'side_conversation' && this.options.isSessionActive(input.sourceSessionId)) {
return copyFailure('session_busy', 'Source Session has an active Turn');
}
@@ -306,7 +310,7 @@ export class HostSessionRevisionCoordinator {
const slice = createConversationCopySlice(
source.messages,
input.sourceTurnId,
- kind === 'branch' ? 'through' : 'before',
+ kind === 'revision' ? 'before' : 'through',
);
if (!slice) {
return copyFailure('invalid_request', 'Source turn does not exist');
@@ -388,6 +392,7 @@ export class HostSessionRevisionCoordinator {
return copyFailure(linkedReferences.code, linkedReferences.message);
}
if (
+ kind !== 'side_conversation' &&
archivePreflight.serializedResults.some((serializedResult) =>
archivedToolResultContainsConversationOwnedReferences(
serializedResult,
@@ -447,10 +452,34 @@ export class HostSessionRevisionCoordinator {
}
try {
+ const archivedSnapshotResults = new Map(
+ archivePreflight.results
+ .filter(
+ ({ serializedResult }) =>
+ archivedToolResultContainsLinkedChildReferences(serializedResult) ||
+ archivedToolResultContainsConversationOwnedReferences(
+ serializedResult,
+ input.sourceSessionId,
+ linkedReferences.references,
+ ),
+ )
+ .map(({ descriptor, serializedResult }) => [descriptor.artifactId, serializedResult]),
+ );
const artifactCopy = await this.#artifacts.copyConversationArtifacts({
sourceSessionId: input.sourceSessionId,
targetSessionId: input.targetSessionId,
turnIds: copyTurnIds,
+ ...(kind === 'side_conversation' && archivedSnapshotResults.size > 0
+ ? { excludeArtifactIds: [...archivedSnapshotResults.keys()] }
+ : {}),
+ ...(kind === 'side_conversation' && linkedReferences.references.size > 0
+ ? {
+ linkedArtifacts: [...linkedReferences.references].map(([sessionId, references]) => ({
+ sessionId,
+ artifactIds: [...references.artifactIds],
+ })),
+ }
+ : {}),
});
const references = {
mode: 'exact' as const,
@@ -459,12 +488,17 @@ export class HostSessionRevisionCoordinator {
artifactIds: artifactCopy.artifactIds,
relativePaths: artifactCopy.relativePaths,
linkedChildren:
- linkedReferences.references.size > 0
+ kind === 'side_conversation'
? {
- mode: 'preserve_validated' as const,
- references: linkedReferences.references,
+ mode: 'snapshot' as const,
+ archivedResults: archivedSnapshotResults,
}
- : { mode: 'reject' as const },
+ : linkedReferences.references.size > 0
+ ? {
+ mode: 'preserve_validated' as const,
+ references: linkedReferences.references,
+ }
+ : { mode: 'reject' as const },
};
const runtimeCopy = await cloneConversationRuntimeLedger({
plan,
@@ -481,6 +515,7 @@ export class HostSessionRevisionCoordinator {
turnIds: copyTurnIds,
...(slice.beforeTs === undefined ? {} : { beforeTs: slice.beforeTs }),
runIdMap: runtimeCopy.runIdMap,
+ ...(kind === 'side_conversation' ? { linkedChildren: 'snapshot' as const } : {}),
});
if (copiedMessages.length > 0) {
await this.#stores.sessionStore.appendMessages(input.targetSessionId, [...copiedMessages]);
@@ -523,7 +558,14 @@ export class HostSessionRevisionCoordinator {
copiedMessages: readonly StoredMessage[],
copyTurnIds: readonly string[],
): Promise<
- | { readonly ok: true; readonly serializedResults: readonly string[] }
+ | {
+ readonly ok: true;
+ readonly results: readonly {
+ readonly descriptor: ArchivedToolResultCopyDescriptor;
+ readonly serializedResult: string;
+ }[];
+ readonly serializedResults: readonly string[];
+ }
| { readonly ok: false; readonly outcome: ConversationCopyOutcome }
> {
const archives = collectArchivedToolResultPlaceholders(
@@ -537,7 +579,10 @@ export class HostSessionRevisionCoordinator {
outcome: copyFailure('persistence_failed', 'Archived tool result metadata is invalid'),
};
}
- const serializedResults: string[] = [];
+ const results: Array<{
+ descriptor: ArchivedToolResultCopyDescriptor;
+ serializedResult: string;
+ }> = [];
for (const archive of archives) {
const read = await this.#artifacts
.readTextInSession(sourceSessionId, archive.artifactId, {
@@ -557,13 +602,17 @@ export class HostSessionRevisionCoordinator {
),
};
}
- serializedResults.push(read.text);
+ results.push({ descriptor: archive, serializedResult: read.text });
}
- return { ok: true, serializedResults };
+ return {
+ ok: true,
+ results,
+ serializedResults: results.map(({ serializedResult }) => serializedResult),
+ };
}
async #createInput(
- kind: ConversationCopyKind,
+ kind: ConversationCopySemanticKind,
input: SessionConversationCopyInput,
requestFingerprint: `sha256:${string}`,
source: SessionHeader,
@@ -578,17 +627,21 @@ export class HostSessionRevisionCoordinator {
collaborationMode: source.collaborationMode ?? 'agent',
orchestrationMode: source.orchestrationMode ?? 'default',
name: source.name,
- labels: [...source.labels],
+ labels:
+ kind === 'side_conversation'
+ ? [...new Set([...source.labels, SIDE_CONVERSATION_SESSION_LABEL])]
+ : [...source.labels],
conversationCopy: {
- kind,
+ kind: persistedConversationCopyKind(kind),
sourceSessionId: input.sourceSessionId,
sourceTurnId: input.sourceTurnId,
requestFingerprint,
state: 'preparing',
+ ...(kind === 'side_conversation' ? { intent: kind } : {}),
},
status: 'active',
};
- if (kind === 'branch') {
+ if (kind !== 'revision') {
return {
...common,
parentSessionId: input.sourceSessionId,
@@ -617,7 +670,7 @@ export class HostSessionRevisionCoordinator {
}
async #resolveExistingTarget(
- kind: ConversationCopyKind,
+ kind: ConversationCopySemanticKind,
input: SessionConversationCopyInput,
requestFingerprint: `sha256:${string}`,
discardPreparing: boolean,
@@ -640,7 +693,8 @@ export class HostSessionRevisionCoordinator {
}
const copy = probe.record.header.conversationCopy;
if (
- copy?.kind !== kind ||
+ copy?.kind !== persistedConversationCopyKind(kind) ||
+ copy.intent !== (kind === 'side_conversation' ? kind : undefined) ||
copy.sourceSessionId !== input.sourceSessionId ||
copy.sourceTurnId !== input.sourceTurnId ||
copy.requestFingerprint !== requestFingerprint
@@ -679,7 +733,7 @@ export class HostSessionRevisionCoordinator {
}
async #rollbackIncompleteCopy(
- kind: ConversationCopyKind,
+ kind: ConversationCopySemanticKind,
input: SessionConversationCopyInput,
requestFingerprint: `sha256:${string}`,
message: string,
@@ -703,7 +757,7 @@ export class HostSessionRevisionCoordinator {
}
async #unknownAfterCommitAttempt(
- kind: ConversationCopyKind,
+ kind: ConversationCopySemanticKind,
input: SessionConversationCopyInput,
requestFingerprint: `sha256:${string}`,
message: string,
@@ -771,27 +825,24 @@ function isConversationRuntimeFactRewriteUnsupported(error: unknown): boolean {
}
function conversationCopyFingerprint(
- kind: ConversationCopyKind,
+ kind: ConversationCopySemanticKind,
input: SessionConversationCopyInput,
): `sha256:${string}` {
// The optimistic source revision guards only the initial create. Once this
// target exists, its stable identity must resolve the committed outcome even
// if a reconnecting Client observes a newer source revision.
- return `sha256:${createHash('sha256')
- .update(
- JSON.stringify([
- 'session.conversation-copy.v1',
- kind,
- input.sourceSessionId,
- input.targetSessionId,
- input.sourceTurnId,
- ]),
- )
- .digest('hex')}`;
+ const identity = [
+ kind === 'side_conversation' ? 'session.conversation-copy.v2' : 'session.conversation-copy.v1',
+ kind,
+ input.sourceSessionId,
+ input.targetSessionId,
+ input.sourceTurnId,
+ ];
+ return `sha256:${createHash('sha256').update(JSON.stringify(identity)).digest('hex')}`;
}
function conversationCopyStartNote(
- kind: ConversationCopyKind,
+ kind: ConversationCopySemanticKind,
input: SessionConversationCopyInput,
createInput: ConversationCopyCreateInput,
): StoredMessage {
@@ -801,7 +852,7 @@ function conversationCopyStartNote(
ts: Date.now(),
kind: 'session_start',
data:
- kind === 'branch'
+ kind !== 'revision'
? {
parentSessionId: input.sourceSessionId,
branchOfTurnId: input.sourceTurnId,
@@ -816,6 +867,17 @@ function conversationCopyStartNote(
};
}
+function conversationCopySemanticKind(
+ kind: ConversationCopyKind,
+ input: SessionConversationCopyInput,
+): ConversationCopySemanticKind {
+ return kind === 'branch' && input.intent === 'side_conversation' ? input.intent : kind;
+}
+
+function persistedConversationCopyKind(kind: ConversationCopySemanticKind): ConversationCopyKind {
+ return kind === 'revision' ? 'revision' : 'branch';
+}
+
function isRevisionStartData(value: unknown): boolean {
return (
!!value &&
diff --git a/packages/runtime-host/src/server/session-revision-graph-references.ts b/packages/runtime-host/src/server/session-revision-graph-references.ts
index 6aba82b59c..9cd3070324 100644
--- a/packages/runtime-host/src/server/session-revision-graph-references.ts
+++ b/packages/runtime-host/src/server/session-revision-graph-references.ts
@@ -26,7 +26,7 @@ import {
} from '@maka/runtime/conversation-copy';
import type { InteractiveArtifactStoreWriter } from '@maka/storage/artifact-stores';
-type ConversationCopyKind = 'branch' | 'revision';
+type ConversationCopyKind = 'branch' | 'revision' | 'side_conversation';
export type AgentGraphRevisionReferencePreparation =
| {
@@ -98,49 +98,81 @@ export async function prepareAgentGraphRevisionReferences(
return { ok: true, references: new Map() };
}
const requestedChildIds = new Set(requests.map((request) => request.childSessionId));
+ const unrepresentedChildren = directChildren.filter((child) => !requestedChildIds.has(child.id));
if (
- directChildren.some((child) => !child.subagentParent?.graph || !requestedChildIds.has(child.id))
+ input.kind === 'side_conversation' &&
+ unrepresentedChildren.some((child) => dependencies.isSessionActive(child.id))
) {
- return failure(
- 'operation_unavailable',
- 'Session revision requires a terminal result for every retained Agent Graph child',
- );
+ return failure('session_busy', 'A retained linked child is still active');
}
-
const headersById = new Map(input.sessionHeaders.map((header) => [header.id, header]));
- try {
- if ((await dependencies.graph.readSessionState(input.sourceSessionId)) === 'live') {
- return failure('session_busy', 'A retained Agent Graph is not terminal');
- }
- } catch {
- return failure('operation_unavailable', 'Retained Agent Graph state is unavailable');
- }
const referencedGraphs = new Map>();
- for (const request of requests) {
- const parent = headersById.get(request.childSessionId)?.subagentParent;
- if (!parent?.graph) continue;
+ const retainGraph = (header: SessionHeader | undefined) => {
+ const parent = header?.subagentParent;
+ if (!parent?.graph) return;
const graphIds = referencedGraphs.get(parent.parentSessionId) ?? new Set();
graphIds.add(parent.graph.graphId);
referencedGraphs.set(parent.parentSessionId, graphIds);
+ };
+ for (const request of requests) retainGraph(headersById.get(request.childSessionId));
+ if (input.kind === 'side_conversation') {
+ for (const child of directChildren) retainGraph(child);
}
- for (const [rootSessionId, graphIds] of referencedGraphs) {
- for (const graphId of graphIds) {
- let state: 'absent' | 'live' | 'terminal';
- try {
- state = await dependencies.graph.readGraphState(rootSessionId, graphId);
- } catch {
- return failure('operation_unavailable', 'Retained Agent Graph state is unavailable');
- }
- if (state === 'live') {
+ const retainedSessionGraphFailure = async () => {
+ try {
+ if ((await dependencies.graph.readSessionState(input.sourceSessionId)) === 'live') {
return failure('session_busy', 'A retained Agent Graph is not terminal');
}
- if (state === 'absent') {
- return failure(
- 'operation_unavailable',
- 'Retained Agent Graph control state is unavailable',
- );
+ } catch {
+ return failure('operation_unavailable', 'Retained Agent Graph state is unavailable');
+ }
+ return undefined;
+ };
+ const retainedExactGraphFailure = async () => {
+ for (const [rootSessionId, graphIds] of referencedGraphs) {
+ for (const graphId of graphIds) {
+ let state: 'absent' | 'live' | 'terminal';
+ try {
+ state = await dependencies.graph.readGraphState(rootSessionId, graphId);
+ } catch {
+ return failure('operation_unavailable', 'Retained Agent Graph state is unavailable');
+ }
+ if (state === 'live') {
+ return failure('session_busy', 'A retained Agent Graph is not terminal');
+ }
+ if (state === 'absent') {
+ return failure(
+ 'operation_unavailable',
+ 'Retained Agent Graph control state is unavailable',
+ );
+ }
}
}
+ return undefined;
+ };
+ if (input.kind === 'side_conversation') {
+ const graphFailure = await retainedExactGraphFailure();
+ if (graphFailure) return graphFailure;
+ }
+ if (
+ directChildren.some(
+ (child) =>
+ (input.kind === 'revision' && !child.subagentParent?.graph) ||
+ !requestedChildIds.has(child.id),
+ )
+ ) {
+ return failure(
+ 'operation_unavailable',
+ input.kind === 'side_conversation'
+ ? 'Side Conversation requires a terminal result for every retained linked child'
+ : 'Session revision requires a terminal result for every retained Agent Graph child',
+ );
+ }
+ if (input.kind === 'revision') {
+ const graphFailure = await retainedSessionGraphFailure();
+ if (graphFailure) return graphFailure;
+ const exactGraphFailure = await retainedExactGraphFailure();
+ if (exactGraphFailure) return exactGraphFailure;
}
const references = new Map();
@@ -151,9 +183,11 @@ export async function prepareAgentGraphRevisionReferences(
const parent = child?.subagentParent;
if (
!child ||
- !parent?.graph ||
+ !parent ||
!familySessionIds.has(parent.parentSessionId) ||
- !referencedGraphs.get(parent.parentSessionId)?.has(parent.graph.graphId) ||
+ (input.kind === 'revision' && !parent.graph) ||
+ (parent.graph !== undefined &&
+ !referencedGraphs.get(parent.parentSessionId)?.has(parent.graph.graphId)) ||
!retainedTurnIds.has(parent.spawnedBy.parentTurnId)
) {
return failure(
diff --git a/packages/runtime/src/__tests__/conversation-copy.test.ts b/packages/runtime/src/__tests__/conversation-copy.test.ts
index 8b9b4116ed..0a40519375 100644
--- a/packages/runtime/src/__tests__/conversation-copy.test.ts
+++ b/packages/runtime/src/__tests__/conversation-copy.test.ts
@@ -35,6 +35,7 @@ import {
createWorkspaceRuntimeStore,
} from '@maka/storage';
import {
+ archivedToolResultContainsLinkedChildReferences,
archivedToolResultContainsConversationOwnedReferences,
cloneConversationRuntimeLedger,
collectConversationCopyLinkedChildReferences,
@@ -231,6 +232,327 @@ test('conversation copy discovers linked children in persisted retired tool resu
);
});
+test('Side Conversation preflight identifies linked-child archive bodies', () => {
+ assert.equal(
+ archivedToolResultContainsLinkedChildReferences(
+ JSON.stringify({
+ kind: 'subagent',
+ childSessionId: 'child-session',
+ agentName: 'Researcher',
+ turnId: 'child-turn',
+ runId: 'child-run',
+ status: 'completed',
+ permissionMode: 'ask',
+ summary: 'done',
+ artifactIds: ['child-artifact'],
+ }),
+ ),
+ true,
+ );
+ assert.equal(
+ archivedToolResultContainsLinkedChildReferences(
+ JSON.stringify({ kind: 'text', text: 'safe result' }),
+ ),
+ false,
+ );
+});
+
+test('Side Conversation snapshots remove linked child ownership identifiers', () => {
+ const message: Extract = {
+ type: 'tool_result',
+ id: 'linked-result',
+ turnId: 'turn-1',
+ ts: 1,
+ toolUseId: 'linked-call',
+ isError: false,
+ content: {
+ kind: 'agent_swarm',
+ status: 'completed',
+ items: [
+ {
+ itemId: 'item-1',
+ index: 0,
+ profile: 'default',
+ started: true,
+ childSessionId: 'child-session',
+ turnId: 'child-turn',
+ runId: 'child-run',
+ resumedFromRunId: 'child-parent-run',
+ status: 'completed',
+ summary: 'The delegated review found one issue.',
+ artifactIds: ['child-artifact'],
+ },
+ ],
+ startedAt: 1,
+ completedAt: 2,
+ durationMs: 1,
+ },
+ };
+ const rewritten = rewriteConversationCopyMessage(message, {
+ mode: 'exact',
+ sourceSessionId: 'session-source',
+ targetSessionId: 'session-target',
+ artifactIds: new Map([['child-artifact', 'child-artifact-snapshot']]),
+ relativePaths: new Map(),
+ linkedChildren: {
+ mode: 'snapshot',
+ archivedResults: new Map(),
+ },
+ runIds: new Map(),
+ runtimeEventIds: new Map(),
+ providerTraceIds: new Map(),
+ });
+
+ assert.equal(rewritten.type, 'tool_result');
+ if (rewritten.type !== 'tool_result' || rewritten.content.kind !== 'agent_swarm') {
+ assert.fail('Expected the Agent Graph result snapshot');
+ }
+ assert.deepEqual(rewritten.content.items, [
+ {
+ itemId: 'item-1',
+ index: 0,
+ profile: 'default',
+ started: true,
+ turnId: 'child-turn',
+ status: 'completed',
+ summary: 'The delegated review found one issue.',
+ artifactIds: ['child-artifact-snapshot'],
+ },
+ ]);
+});
+
+test('Side Conversation snapshots rewrite source-owned Agent Swarm identities', () => {
+ const message: Extract = {
+ type: 'tool_result',
+ id: 'source-owned-result',
+ turnId: 'turn-1',
+ ts: 1,
+ toolUseId: 'source-owned-call',
+ isError: false,
+ content: {
+ kind: 'agent_swarm',
+ status: 'completed',
+ items: [
+ {
+ itemId: 'item-1',
+ index: 0,
+ profile: 'default',
+ started: true,
+ runId: 'run-source',
+ resumedFromRunId: 'run-parent-source',
+ status: 'completed',
+ summary: 'The source-owned run completed.',
+ artifactIds: ['artifact-source'],
+ },
+ ],
+ startedAt: 1,
+ completedAt: 2,
+ durationMs: 1,
+ },
+ };
+ const rewritten = rewriteConversationCopyMessage(message, {
+ mode: 'exact',
+ sourceSessionId: 'session-source',
+ targetSessionId: 'session-target',
+ artifactIds: new Map([['artifact-source', 'artifact-target']]),
+ relativePaths: new Map(),
+ linkedChildren: {
+ mode: 'snapshot',
+ archivedResults: new Map(),
+ },
+ runIds: new Map([
+ ['run-source', 'run-target'],
+ ['run-parent-source', 'run-parent-target'],
+ ]),
+ runtimeEventIds: new Map(),
+ providerTraceIds: new Map(),
+ });
+
+ assert.equal(rewritten.type, 'tool_result');
+ if (rewritten.type !== 'tool_result' || rewritten.content.kind !== 'agent_swarm') {
+ assert.fail('Expected the source-owned Agent Swarm result');
+ }
+ assert.deepEqual(rewritten.content.items, [
+ {
+ itemId: 'item-1',
+ index: 0,
+ profile: 'default',
+ started: true,
+ runId: 'run-target',
+ resumedFromRunId: 'run-parent-target',
+ status: 'completed',
+ summary: 'The source-owned run completed.',
+ artifactIds: ['artifact-target'],
+ },
+ ]);
+});
+
+test('Side Conversation snapshots rewrite source-owned subagent identities', () => {
+ const message: Extract = {
+ type: 'tool_result',
+ id: 'source-owned-result',
+ turnId: 'turn-1',
+ ts: 1,
+ toolUseId: 'source-owned-call',
+ isError: false,
+ content: {
+ kind: 'subagent',
+ agentName: 'Researcher',
+ turnId: 'turn-1',
+ runId: 'run-source',
+ status: 'completed',
+ permissionMode: 'ask',
+ summary: 'The source-owned run completed.',
+ artifactIds: ['artifact-source'],
+ },
+ };
+ const rewritten = rewriteConversationCopyMessage(message, {
+ mode: 'exact',
+ sourceSessionId: 'session-source',
+ targetSessionId: 'session-target',
+ artifactIds: new Map([['artifact-source', 'artifact-target']]),
+ relativePaths: new Map(),
+ linkedChildren: {
+ mode: 'snapshot',
+ archivedResults: new Map(),
+ },
+ runIds: new Map([['run-source', 'run-target']]),
+ runtimeEventIds: new Map(),
+ providerTraceIds: new Map(),
+ });
+
+ assert.equal(rewritten.type, 'tool_result');
+ if (rewritten.type !== 'tool_result' || rewritten.content.kind !== 'subagent') {
+ assert.fail('Expected the source-owned subagent result');
+ }
+ assert.equal(rewritten.content.runId, 'run-target');
+ assert.deepEqual(rewritten.content.artifactIds, ['artifact-target']);
+});
+
+test('Side Conversation snapshots preserve ordinary archived tool results', () => {
+ const message: Extract = {
+ type: 'tool_result',
+ id: 'archived-result',
+ turnId: 'turn-1',
+ ts: 1,
+ toolUseId: 'tool-1',
+ isError: false,
+ content: {
+ kind: 'json',
+ value: {
+ kind: 'maka.archived_tool_result',
+ rewriteVersion: 1,
+ artifactId: 'artifact-source',
+ runtimeEventId: 'event-source',
+ toolCallId: 'tool-1',
+ toolName: 'search',
+ bodySha256: 'a'.repeat(64),
+ originalEstimatedTokens: 42,
+ originalBytes: 128,
+ reason: 'stale_tool_result_pruned_before_compact',
+ },
+ },
+ };
+ const rewritten = rewriteConversationCopyMessage(message, {
+ mode: 'exact',
+ sourceSessionId: 'session-source',
+ targetSessionId: 'session-target',
+ artifactIds: new Map([['artifact-source', 'artifact-target']]),
+ relativePaths: new Map(),
+ linkedChildren: {
+ mode: 'snapshot',
+ archivedResults: new Map(),
+ },
+ runIds: new Map(),
+ runtimeEventIds: new Map([['event-source', 'event-target']]),
+ providerTraceIds: new Map(),
+ });
+
+ assert.equal(rewritten.type, 'tool_result');
+ if (rewritten.type !== 'tool_result' || rewritten.content.kind !== 'json') {
+ assert.fail('Expected an archived JSON tool result');
+ }
+ assert.deepEqual(rewritten.content.value, {
+ kind: 'maka.archived_tool_result',
+ rewriteVersion: 1,
+ artifactId: 'artifact-target',
+ runtimeEventId: 'event-target',
+ toolCallId: 'tool-1',
+ toolName: 'search',
+ bodySha256: 'a'.repeat(64),
+ originalEstimatedTokens: 42,
+ originalBytes: 128,
+ reason: 'stale_tool_result_pruned_before_compact',
+ });
+});
+
+test('Side Conversation snapshots retire archived linked-child results', () => {
+ const message: Extract = {
+ type: 'tool_result',
+ id: 'archived-result',
+ turnId: 'turn-1',
+ ts: 1,
+ toolUseId: 'tool-1',
+ isError: false,
+ content: {
+ kind: 'json',
+ value: {
+ kind: 'maka.archived_tool_result',
+ rewriteVersion: 1,
+ artifactId: 'artifact-source',
+ runtimeEventId: 'event-source',
+ toolCallId: 'tool-1',
+ toolName: 'subagent',
+ bodySha256: 'b'.repeat(64),
+ originalEstimatedTokens: 42,
+ originalBytes: 128,
+ reason: 'stale_tool_result_pruned_before_compact',
+ },
+ },
+ };
+ const rewritten = rewriteConversationCopyMessage(message, {
+ mode: 'exact',
+ sourceSessionId: 'session-source',
+ targetSessionId: 'session-target',
+ artifactIds: new Map([['child-artifact', 'child-artifact-snapshot']]),
+ relativePaths: new Map(),
+ linkedChildren: {
+ mode: 'snapshot',
+ archivedResults: new Map([
+ [
+ 'artifact-source',
+ JSON.stringify({
+ kind: 'subagent',
+ childSessionId: 'child-session',
+ agentName: 'Researcher',
+ turnId: 'child-turn',
+ runId: 'child-run',
+ status: 'completed',
+ permissionMode: 'ask',
+ summary: 'The archived review found one issue.',
+ artifactIds: ['child-artifact'],
+ }),
+ ],
+ ]),
+ },
+ runIds: new Map(),
+ runtimeEventIds: new Map([['event-source', 'event-target']]),
+ providerTraceIds: new Map(),
+ });
+
+ assert.equal(rewritten.type, 'tool_result');
+ if (rewritten.type !== 'tool_result') assert.fail('Expected a tool result');
+ assert.deepEqual(rewritten.content, {
+ kind: 'subagent',
+ agentName: 'Researcher',
+ turnId: 'child-turn',
+ status: 'completed',
+ permissionMode: 'ask',
+ summary: 'The archived review found one issue.',
+ artifactIds: ['child-artifact-snapshot'],
+ });
+});
+
test('conversation copy slices exact turns on inclusive and exclusive boundaries', () => {
const messages = [
{ type: 'user', id: 'user-1', turnId: 'turn-1', ts: 1, text: 'first' },
diff --git a/packages/runtime/src/conversation-copy.ts b/packages/runtime/src/conversation-copy.ts
index bff9cc5309..d013f0b7d7 100644
--- a/packages/runtime/src/conversation-copy.ts
+++ b/packages/runtime/src/conversation-copy.ts
@@ -87,6 +87,10 @@ export type ConversationCopyArtifactReferenceMap =
readonly relativePaths: ReadonlyMap;
readonly linkedChildren:
| { readonly mode: 'reject' }
+ | {
+ readonly mode: 'snapshot';
+ readonly archivedResults: ReadonlyMap;
+ }
| {
readonly mode: 'preserve_validated';
readonly references: ReadonlyMap;
@@ -532,6 +536,20 @@ export function archivedToolResultContainsConversationOwnedReferences(
return false;
}
+export function archivedToolResultContainsLinkedChildReferences(serializedResult: string): boolean {
+ const value = deserializeToolResultArchive(serializedResult);
+ if (isArchivedToolResultPlaceholder(value)) return false;
+ try {
+ return (
+ conversationCopyLinkedChildReferences(
+ decodePersistedToolResultContent(markPersisted(value)),
+ ).length > 0
+ );
+ } catch {
+ return false;
+ }
+}
+
export function conversationCopyLinkedChildReferences(
content: ToolResultContent,
): readonly ConversationCopyLinkedChildReference[] {
@@ -1010,9 +1028,11 @@ function rewriteRuntimeEventReferences(
}
: {}),
...(event.refs.artifactId
- ? {
- artifactId: rewriteOwnedArtifactId(event.refs.artifactId, references),
- }
+ ? archivedSnapshotResult(event.refs.artifactId, references) !== undefined
+ ? {}
+ : {
+ artifactId: rewriteOwnedArtifactId(event.refs.artifactId, references),
+ }
: {}),
...(event.refs.sourceInvocationId
? {
@@ -1111,6 +1131,8 @@ function rewriteToolResultContent(
return { ...content, ref: rewriteStorageRef(content.ref, references) };
}
if (content.kind === 'archived_tool_result') {
+ const snapshot = rewriteArchivedSnapshot(content, references);
+ if (snapshot) return snapshot;
return {
...content,
runtimeEventId: rewriteOwnedId(
@@ -1124,12 +1146,21 @@ function rewriteToolResultContent(
};
}
if (content.kind === 'json' && isArchivedToolResultPlaceholder(content.value)) {
+ const snapshot = rewriteArchivedSnapshot(content.value, references);
+ if (snapshot) return snapshot;
return {
...content,
value: rewriteArchivedToolResult(content.value, references),
};
}
if (content.kind === 'subagent') {
+ if (linkedChildrenAreSnapshots(references) && content.childSessionId) {
+ const { childSessionId: _childSessionId, runId: _runId, ...snapshot } = content;
+ return {
+ ...snapshot,
+ artifactIds: rewriteSnapshotArtifactIds(content.artifactIds, references),
+ };
+ }
return {
...content,
...(content.runId
@@ -1153,6 +1184,18 @@ function rewriteToolResultContent(
return {
...content,
items: content.items.map((item) => {
+ if (linkedChildrenAreSnapshots(references) && item.childSessionId) {
+ const {
+ childSessionId: _childSessionId,
+ runId: _runId,
+ resumedFromRunId: _resumedFromRunId,
+ ...snapshot
+ } = item;
+ return {
+ ...snapshot,
+ artifactIds: rewriteSnapshotArtifactIds(item.artifactIds, references),
+ };
+ }
return {
...item,
...(item.runId
@@ -1183,6 +1226,8 @@ function rewriteRuntimeToolResult(
references: ConversationCopyMessageReferenceMap,
): unknown {
if (isArchivedToolResultPlaceholder(value)) {
+ const snapshot = rewriteArchivedSnapshot(value, references);
+ if (snapshot) return snapshot;
return rewriteArchivedToolResult(value, references);
}
let content: ToolResultContent;
@@ -1206,6 +1251,7 @@ function validatedExternalChildReferences(
references: ConversationCopyMessageReferenceMap,
): ConversationCopyExternalChildReferences | undefined {
if (references.mode === 'preserve_external') return undefined;
+ if (references.linkedChildren.mode === 'snapshot') return undefined;
if (references.linkedChildren.mode === 'reject') {
throw new Error(`Conversation copy cannot retain linked child Session ${childSessionId}`);
}
@@ -1216,6 +1262,81 @@ function validatedExternalChildReferences(
return external;
}
+function linkedChildrenAreSnapshots(references: ConversationCopyMessageReferenceMap): boolean {
+ return references.mode === 'exact' && references.linkedChildren.mode === 'snapshot';
+}
+
+function rewriteSnapshotArtifactIds(
+ artifactIds: readonly string[],
+ references: ConversationCopyMessageReferenceMap,
+): readonly string[] {
+ if (references.mode !== 'exact' || references.linkedChildren.mode !== 'snapshot') {
+ return artifactIds;
+ }
+ return artifactIds.map((artifactId) =>
+ requiredMappedId(references.artifactIds, artifactId, 'linked Artifact'),
+ );
+}
+
+function rewriteArchivedSnapshot(
+ value:
+ | ArchivedToolResultPlaceholder
+ | Extract,
+ references: ConversationCopyMessageReferenceMap,
+): ToolResultContent | undefined {
+ const serializedResult = archivedSnapshotResult(value.artifactId, references);
+ if (serializedResult === undefined) return undefined;
+ const archived = deserializeToolResultArchive(serializedResult);
+ if (isArchivedToolResultPlaceholder(archived)) {
+ return unavailableArchivedToolResult(value, references);
+ }
+ try {
+ const decoded = decodePersistedToolResultContent(markPersisted(archived));
+ return decoded.kind === 'archived_tool_result'
+ ? unavailableArchivedToolResult(value, references)
+ : rewriteToolResultContent(decoded, references);
+ } catch {
+ return unavailableArchivedToolResult(value, references);
+ }
+}
+
+function archivedSnapshotResult(
+ artifactId: string | undefined,
+ references: ConversationCopyMessageReferenceMap,
+): string | undefined {
+ if (
+ artifactId === undefined ||
+ references.mode !== 'exact' ||
+ references.linkedChildren.mode !== 'snapshot'
+ ) {
+ return undefined;
+ }
+ return references.linkedChildren.archivedResults.get(artifactId);
+}
+
+function unavailableArchivedToolResult(
+ value:
+ | ArchivedToolResultPlaceholder
+ | Extract,
+ references: ConversationCopyMessageReferenceMap,
+): Extract {
+ return {
+ kind: 'archived_tool_result',
+ status: 'missing',
+ runtimeEventId: rewriteOwnedId(
+ value.runtimeEventId,
+ references.runtimeEventIds,
+ 'RuntimeEvent',
+ ),
+ toolCallId: value.toolCallId,
+ toolName: value.toolName,
+ originalEstimatedTokens: value.originalEstimatedTokens,
+ originalBytes: value.originalBytes,
+ rewriteVersion: value.rewriteVersion,
+ reason: value.reason,
+ };
+}
+
function rewriteLinkedRunId(
sourceId: string,
childSessionId: string | undefined,
diff --git a/packages/storage/src/__tests__/artifact-store.test.ts b/packages/storage/src/__tests__/artifact-store.test.ts
index db16676269..245a57980b 100644
--- a/packages/storage/src/__tests__/artifact-store.test.ts
+++ b/packages/storage/src/__tests__/artifact-store.test.ts
@@ -322,6 +322,63 @@ describe('SQLite Artifact store', () => {
});
});
+ test('excludes selected Artifacts from a conversation snapshot', async () => {
+ await withWorkspace(async (root) => {
+ const authority = createArtifactStoreWriteAuthority(root);
+ await authority.recover();
+ const { store } = authority;
+ await store.create({
+ ...artifactInput('retained-artifact', 'retained', 10),
+ turnId: 'turn-retained',
+ });
+ await store.create({
+ ...artifactInput('excluded-archive', 'archived child result', 11),
+ turnId: 'turn-retained',
+ source: 'tool_result_archive',
+ });
+
+ const copied = await store.copyConversationArtifacts({
+ sourceSessionId: 'session-1',
+ targetSessionId: 'session-copy',
+ turnIds: ['turn-retained'],
+ excludeArtifactIds: ['excluded-archive'],
+ });
+
+ assert.equal(copied.artifactIds.has('excluded-archive'), false);
+ assert.deepEqual(
+ (await store.list('session-copy')).map((record) => record.name),
+ ['retained-artifact.txt'],
+ );
+ assert.equal((await store.get('excluded-archive'))?.sessionId, 'session-1');
+ });
+ });
+
+ test('copies explicit linked child Artifacts into a conversation snapshot', async () => {
+ await withWorkspace(async (root) => {
+ const authority = createArtifactStoreWriteAuthority(root);
+ await authority.recover();
+ const { store } = authority;
+ await store.create({
+ ...artifactInput('child-artifact', 'child result', 10),
+ sessionId: 'child-session',
+ turnId: 'child-turn',
+ });
+
+ const copied = await store.copyConversationArtifacts({
+ sourceSessionId: 'session-1',
+ targetSessionId: 'session-copy',
+ turnIds: ['turn-retained'],
+ linkedArtifacts: [{ sessionId: 'child-session', artifactIds: ['child-artifact'] }],
+ });
+
+ const copiedId = copied.artifactIds.get('child-artifact');
+ assert.ok(copiedId);
+ assert.deepEqual(await store.readText(copiedId), { ok: true, text: 'child result' });
+ assert.equal((await store.get(copiedId))?.sessionId, 'session-copy');
+ assert.equal((await store.get('child-artifact'))?.sessionId, 'child-session');
+ });
+ });
+
test('user delete evaluates current-generation policy before tombstone state', async () => {
await withWorkspace(async (root) => {
const authority = createArtifactStoreWriteAuthority(root);
diff --git a/packages/storage/src/__tests__/task-ledger-authority.test.ts b/packages/storage/src/__tests__/task-ledger-authority.test.ts
index d4c9d5b7c6..d0ca59dc87 100644
--- a/packages/storage/src/__tests__/task-ledger-authority.test.ts
+++ b/packages/storage/src/__tests__/task-ledger-authority.test.ts
@@ -112,6 +112,98 @@ describe('interactive task ledger authority', () => {
);
});
});
+
+ test('copies child-owned tasks into an ownership-free Side Conversation snapshot', async () => {
+ await withInteractiveOwner(async ({ writer }) => {
+ const sourceSessionId = 'source-session';
+ const targetSessionId = 'side-conversation';
+ const created = await writer.create(sourceSessionId, [{ subject: 'Delegated review' }], {
+ turnId: 'root-turn',
+ source: 'tool',
+ actor: 'main_agent',
+ });
+ const taskId = created.created[0]!.id;
+ await writer.claim(
+ sourceSessionId,
+ taskId,
+ {
+ actor: 'child_agent',
+ sessionId: 'child-session',
+ agentId: 'reviewer',
+ runId: 'child-run',
+ turnId: 'child-turn',
+ },
+ {
+ turnId: 'root-turn',
+ runId: 'child-run',
+ source: 'tool',
+ actor: 'child_agent',
+ },
+ );
+ const legacy = await writer.create(sourceSessionId, [{ subject: 'Legacy child note' }], {
+ turnId: 'root-turn',
+ runId: 'legacy-child-run',
+ source: 'tool',
+ actor: 'child_agent',
+ });
+
+ await writer.copyConversationTaskLedger({
+ sourceSessionId,
+ targetSessionId,
+ turnIds: ['root-turn'],
+ runIdMap: [],
+ linkedChildren: 'snapshot',
+ });
+
+ const copied = await writer.list(targetSessionId);
+ assert.deepEqual(
+ copied.map((task) => ({ id: task.id, subject: task.subject, status: task.status })),
+ [
+ { id: taskId, subject: 'Delegated review', status: 'in_progress' },
+ { id: legacy.created[0]!.id, subject: 'Legacy child note', status: 'pending' },
+ ],
+ );
+ assert.ok(copied.every((task) => task.owner === undefined));
+ assert.equal((await writer.get(sourceSessionId, taskId))?.owner?.sessionId, 'child-session');
+ assert.equal((await writer.get(sourceSessionId, taskId))?.owner?.runId, 'child-run');
+ });
+ });
+
+ test('preserves main-agent ownership on child-authored snapshot events', async () => {
+ await withInteractiveOwner(async ({ writer }) => {
+ const sourceSessionId = 'source-main-owned';
+ const targetSessionId = 'side-main-owned';
+ const created = await writer.create(sourceSessionId, [{ subject: 'Parent-owned work' }], {
+ turnId: 'root-turn',
+ runId: 'root-run',
+ source: 'tool',
+ actor: 'main_agent',
+ });
+ await writer.update(
+ sourceSessionId,
+ created.created[0]!.id,
+ { subject: 'Child reported progress' },
+ {
+ turnId: 'root-turn',
+ runId: 'child-run',
+ source: 'tool',
+ actor: 'child_agent',
+ },
+ );
+
+ await writer.copyConversationTaskLedger({
+ sourceSessionId,
+ targetSessionId,
+ turnIds: ['root-turn'],
+ runIdMap: [{ sourceRunId: 'root-run', targetRunId: 'copied-root-run' }],
+ linkedChildren: 'snapshot',
+ });
+
+ const [copied] = await writer.list(targetSessionId);
+ assert.equal(copied?.owner?.actor, 'main_agent');
+ assert.equal(copied?.owner?.runId, 'copied-root-run');
+ });
+ });
});
async function withInteractiveOwner(
diff --git a/packages/storage/src/artifact-store.ts b/packages/storage/src/artifact-store.ts
index bcc802d443..dc43a65241 100644
--- a/packages/storage/src/artifact-store.ts
+++ b/packages/storage/src/artifact-store.ts
@@ -158,6 +158,11 @@ export interface ConversationArtifactCopyInput {
readonly sourceSessionId: string;
readonly targetSessionId: string;
readonly turnIds: readonly string[];
+ readonly excludeArtifactIds?: readonly string[];
+ readonly linkedArtifacts?: readonly {
+ readonly sessionId: string;
+ readonly artifactIds: readonly string[];
+ }[];
}
export interface ConversationArtifactCopyResult {
@@ -383,21 +388,52 @@ class SqliteArtifactStore implements ArtifactAuthorityStore {
throw new Error('Artifact conversation copy requires distinct Sessions');
}
const turnIds = new Set(input.turnIds);
+ const excludedArtifactIds = new Set(input.excludeArtifactIds ?? []);
for (const turnId of turnIds) assertArtifactTurnKey(turnId);
+ const linkedArtifacts = input.linkedArtifacts ?? [];
+ const requestedLinkedArtifactIds = new Map>();
+ for (const linked of linkedArtifacts) {
+ assertCanonicalArtifactEntityId(linked.sessionId, 'sessionId');
+ if (linked.sessionId === input.targetSessionId) {
+ throw new Error('Linked Artifact copy requires a distinct source Session');
+ }
+ const artifactIds = requestedLinkedArtifactIds.get(linked.sessionId) ?? new Set();
+ for (const artifactId of linked.artifactIds) {
+ assertCanonicalArtifactEntityId(artifactId, 'id');
+ artifactIds.add(artifactId);
+ }
+ requestedLinkedArtifactIds.set(linked.sessionId, artifactIds);
+ }
const records = await this.enqueue(async () => {
await this.load();
- return this.records
+ const selected = this.records
.filter(
- (record) => record.sessionId === input.sourceSessionId && turnIds.has(record.turnId),
+ (record) =>
+ record.sessionId === input.sourceSessionId &&
+ turnIds.has(record.turnId) &&
+ !excludedArtifactIds.has(record.id),
)
.map((record) => ({ ...record }));
+ for (const [sessionId, artifactIds] of requestedLinkedArtifactIds) {
+ for (const artifactId of artifactIds) {
+ const record = this.records.find(
+ (candidate) =>
+ candidate.sessionId === sessionId &&
+ candidate.id === artifactId &&
+ candidate.status !== 'deleted',
+ );
+ if (!record) throw new Error(`Linked Artifact ${artifactId} could not be copied`);
+ selected.push({ ...record });
+ }
+ }
+ return selected;
});
const artifactIds = new Map();
const relativePaths = new Map();
for (const record of records) {
const targetId = conversationCopyArtifactId(
- input.sourceSessionId,
+ record.sessionId,
input.targetSessionId,
record.id,
);
diff --git a/packages/storage/src/artifact-stores.ts b/packages/storage/src/artifact-stores.ts
index 4cdd20ae1d..feaa5a94e2 100644
--- a/packages/storage/src/artifact-stores.ts
+++ b/packages/storage/src/artifact-stores.ts
@@ -152,6 +152,21 @@ function createWriterFacade(
const acceptedInput: ConversationArtifactCopyInput = Object.freeze({
...input,
turnIds: Object.freeze([...input.turnIds]),
+ ...(input.excludeArtifactIds
+ ? { excludeArtifactIds: Object.freeze([...input.excludeArtifactIds]) }
+ : {}),
+ ...(input.linkedArtifacts
+ ? {
+ linkedArtifacts: Object.freeze(
+ input.linkedArtifacts.map((linked) =>
+ Object.freeze({
+ sessionId: linked.sessionId,
+ artifactIds: Object.freeze([...linked.artifactIds]),
+ }),
+ ),
+ ),
+ }
+ : {}),
});
return run(() => store.copyConversationArtifacts(acceptedInput));
},
diff --git a/packages/storage/src/session-conversation-copy.ts b/packages/storage/src/session-conversation-copy.ts
index db33111d7d..eff3083ae3 100644
--- a/packages/storage/src/session-conversation-copy.ts
+++ b/packages/storage/src/session-conversation-copy.ts
@@ -41,6 +41,7 @@ export function isValidConversationCopyTransition(
previous.sourceSessionId === next.sourceSessionId &&
previous.sourceTurnId === next.sourceTurnId &&
previous.requestFingerprint === next.requestFingerprint &&
+ previous.intent === next.intent &&
(previous.state !== 'committed' || next.state === 'committed') &&
(previous.state !== 'preparing' || next.state === 'preparing' || next.state === 'committed')
);
diff --git a/packages/storage/src/task-ledger-authority.ts b/packages/storage/src/task-ledger-authority.ts
index 9d2f2d8e47..b63328b706 100644
--- a/packages/storage/src/task-ledger-authority.ts
+++ b/packages/storage/src/task-ledger-authority.ts
@@ -145,6 +145,7 @@ function createInteractiveWriterFacade(
...input,
turnIds: Object.freeze([...input.turnIds]),
runIdMap: Object.freeze(input.runIdMap.map((entry) => Object.freeze({ ...entry }))),
+ ...(input.linkedChildren ? { linkedChildren: input.linkedChildren } : {}),
});
return run(() => store.copyConversationTaskLedger(acceptedInput));
},
diff --git a/packages/storage/src/task-ledger-store.ts b/packages/storage/src/task-ledger-store.ts
index 78d40d508b..ceb62d1df6 100644
--- a/packages/storage/src/task-ledger-store.ts
+++ b/packages/storage/src/task-ledger-store.ts
@@ -65,6 +65,7 @@ export interface ConversationTaskLedgerCopyInput {
readonly sourceRunId: string;
readonly targetRunId: string;
}[];
+ readonly linkedChildren?: 'preserve' | 'snapshot';
}
export interface TaskLedgerAuthorityStore extends TaskLedgerStore {
@@ -174,7 +175,13 @@ class SqliteTaskLedgerStoreImpl implements SqliteTaskLedgerStore {
throw new Error('Task Ledger events cross the conversation-copy boundary');
}
selected.push(
- rewriteConversationTaskEvent(event, input.sourceSessionId, input.targetSessionId, runIds),
+ rewriteConversationTaskEvent(
+ event,
+ input.sourceSessionId,
+ input.targetSessionId,
+ runIds,
+ input.linkedChildren ?? 'preserve',
+ ),
);
}
if (selected.length === 0) return;
@@ -787,29 +794,37 @@ function rewriteConversationTaskEvent(
sourceSessionId: string,
targetSessionId: string,
runIds: ReadonlyMap,
+ linkedChildren: 'preserve' | 'snapshot',
): TaskLedgerEvent {
- const owner = event.task.owner;
+ const { owner, ...task } = event.task;
+ const snapshotChildOwner = linkedChildren === 'snapshot' && owner?.actor === 'child_agent';
+ const snapshotChildRun = linkedChildren === 'snapshot' && event.actor === 'child_agent';
const rewrittenOwner =
owner === undefined
? undefined
- : {
- ...owner,
- ...(owner.sessionId === sourceSessionId ? { sessionId: targetSessionId } : {}),
- ...(owner.runId
- ? {
- runId: requiredConversationCopyRunId(runIds, owner.runId),
- }
- : {}),
- };
+ : snapshotChildOwner
+ ? undefined
+ : {
+ ...owner,
+ ...(owner.sessionId === sourceSessionId ? { sessionId: targetSessionId } : {}),
+ ...(owner.runId
+ ? {
+ runId: requiredConversationCopyRunId(runIds, owner.runId),
+ }
+ : {}),
+ };
const refs =
event.refs === undefined
? undefined
- : {
- ...event.refs,
- ...(event.refs.runId
- ? { runId: requiredConversationCopyRunId(runIds, event.refs.runId) }
- : {}),
- };
+ : (() => {
+ const { runId: sourceRunId, ...preserved } = event.refs;
+ return {
+ ...preserved,
+ ...(sourceRunId && !snapshotChildRun
+ ? { runId: requiredConversationCopyRunId(runIds, sourceRunId) }
+ : {}),
+ };
+ })();
return {
...event,
eventId: `task-copy-${createHash('sha256')
@@ -817,7 +832,7 @@ function rewriteConversationTaskEvent(
.digest('hex')}`,
sessionId: targetSessionId,
task: {
- ...event.task,
+ ...task,
...(rewrittenOwner ? { owner: rewrittenOwner } : {}),
},
...(refs ? { refs } : {}),