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 7ba9c351de..a77e117d70 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 @@ -870,7 +870,10 @@ async function seedSource( const artifact = await artifacts.create({ id: 'source-artifact', sessionId: source.id, - turnId: 'turn-1', + // A user upload carries the uploadId sentinel as its turnId, not a + // conversation turn — so it is not selected by the turn-scoped artifact + // copy and must be carried by the referenced-attachment include path. + turnId: 'upload-source-artifact', name: 'source.txt', kind: 'file', content: 'retained bytes', @@ -905,7 +908,7 @@ async function seedSource( ref: { kind: 'session_file', sessionId: source.id, - relativePath: artifact.relativePath, + relativePath: artifact.id, }, }, ], @@ -965,7 +968,7 @@ async function seedSource( ref: { kind: 'session_file', sessionId: source.id, - relativePath: artifact.relativePath, + relativePath: artifact.id, }, }, ], @@ -1529,6 +1532,25 @@ async function verifyDurableBranch( const artifacts = await openInteractiveArtifactStoreForWrite(owner.lease); const tasks = await openInteractiveTaskLedgerStoreForWrite(owner.lease); await artifacts.recover(); + // Every copy kind that retains the upload turn must carry a rewritten, + // readable copy of the user-uploaded attachment (regression guard for the + // turn-scoped-only artifact selection that dropped user uploads). + const assertCopiedUpload = async (sessionId: string): Promise => { + const sessionMessages = await execution.sessionStore.readMessagesSnapshot(sessionId); + const uploadMessage = sessionMessages.find( + (message) => message.type === 'user' && message.attachments?.[0], + ); + const uploadRef = + uploadMessage?.type === 'user' ? uploadMessage.attachments?.[0]?.ref : undefined; + assert.equal(uploadRef?.kind, 'session_file', `${sessionId} must retain the upload`); + if (uploadRef?.kind !== 'session_file') return; + assert.equal(uploadRef.sessionId, sessionId); + assert.notEqual(uploadRef.relativePath, 'source-artifact'); + assert.deepEqual(await artifacts.readTextInSession(sessionId, uploadRef.relativePath), { + ok: true, + text: 'retained bytes', + }); + }; const messages = await execution.sessionStore.readMessagesSnapshot(branchSessionId); assert.equal(messages.length, 3); const user = messages.find((message) => message.type === 'user'); @@ -1537,8 +1559,14 @@ async function verifyDurableBranch( assert.equal(ref?.kind, 'session_file'); if (ref?.kind !== 'session_file') assert.fail('Copied attachment must remain session-backed'); assert.equal(ref.sessionId, branchSessionId); - assert.notEqual(ref.relativePath, `${sourceSessionId}/source-artifact-source.txt`); + // The user-upload attachment ref carries the source artifact id; the copy + // must rewrite it to a fresh target artifact id, never leave the source id. + assert.notEqual(ref.relativePath, 'source-artifact'); assert.equal((await artifacts.listPage(branchSessionId, { offset: 0, limit: 10 })).total, 2); + assert.deepEqual(await artifacts.readTextInSession(branchSessionId, ref.relativePath), { + ok: true, + text: 'retained bytes', + }); assert.deepEqual((await tasks.list(branchSessionId)).map((task) => task.subject).sort(), [ 'Legacy child task', 'Retained task', @@ -1562,6 +1590,7 @@ async function verifyDurableBranch( (await execution.sessionStore.readHeaderSnapshot(admittedRevisionTargetId)).revisionState, 'committed', ); + await assertCopiedUpload(admittedRevisionTargetId); assert.equal( (await execution.sessionStore.readHeaderSnapshot(lineageRevisionTargetId)).revisionState, 'committed', @@ -1602,6 +1631,7 @@ async function verifyDurableBranch( (message) => message.turnId !== 'active-source-turn', ), ); + await assertCopiedUpload(activeSourceSideConversationTargetId); const sideConversationRuns = await execution.agentRunStore.listSessionRuns( graphSideConversationTargetId, ); diff --git a/packages/runtime-host/src/server/session-revision-coordinator.ts b/packages/runtime-host/src/server/session-revision-coordinator.ts index 328e280676..ab182f54c1 100644 --- a/packages/runtime-host/src/server/session-revision-coordinator.ts +++ b/packages/runtime-host/src/server/session-revision-coordinator.ts @@ -35,6 +35,7 @@ import { archivedToolResultContainsConversationOwnedReferences, cloneConversationRuntimeLedger, collectConversationCopyLinkedChildReferences, + collectConversationCopySessionFileRefs, createConversationCopySlice, prepareConversationRuntimeLedgerCopy, type ConversationRuntimeLedgerCopyPlan, @@ -387,6 +388,12 @@ export class HostSessionRevisionCoordinator { runtimeEvents: plan.runs.flatMap(({ runtimeEvents }) => runtimeEvents), archivedResults: archivePreflight.serializedResults, }); + const referencedSessionFileIds = collectConversationCopySessionFileRefs({ + sourceSessionId: input.sourceSessionId, + messages: slice.messages, + runtimeEvents: plan.runs.flatMap(({ runtimeEvents }) => runtimeEvents), + archivedResults: archivePreflight.serializedResults, + }); const missingGraphChildSessionIds = agentGraphRevisionAdmissionSessionIds({ sourceSessionId: input.sourceSessionId, sessionHeaders, @@ -493,6 +500,9 @@ export class HostSessionRevisionCoordinator { sourceSessionId: input.sourceSessionId, targetSessionId: input.targetSessionId, turnIds: copyTurnIds, + ...(referencedSessionFileIds.size > 0 + ? { includeArtifactIds: [...referencedSessionFileIds] } + : {}), ...(kind === 'side_conversation' && archivedSnapshotResults.size > 0 ? { excludeArtifactIds: [...archivedSnapshotResults.keys()] } : {}), diff --git a/packages/runtime/src/__tests__/conversation-copy.test.ts b/packages/runtime/src/__tests__/conversation-copy.test.ts index 7d7db16e9e..cf7ccbb3a4 100644 --- a/packages/runtime/src/__tests__/conversation-copy.test.ts +++ b/packages/runtime/src/__tests__/conversation-copy.test.ts @@ -38,6 +38,7 @@ import { archivedToolResultContainsConversationOwnedReferences, cloneConversationRuntimeLedger, collectConversationCopyLinkedChildReferences, + collectConversationCopySessionFileRefs, createConversationCopySlice, prepareConversationRuntimeLedgerCopy, rewriteConversationCopyMessage, @@ -231,6 +232,101 @@ test('conversation copy discovers linked children in persisted retired tool resu ); }); +test('collectConversationCopySessionFileRefs gathers source-Session refs across sites', () => { + const sourceRef = (relativePath: string, sessionId = 'session-source') => ({ + kind: 'session_file' as const, + sessionId, + relativePath, + }); + const messages: StoredMessage[] = [ + { + type: 'user', + id: 'user-1', + turnId: 'turn-1', + ts: 1, + text: 'attached', + attachments: [ + { + kind: 'image', + name: 'upload.png', + mimeType: 'image/png', + bytes: 10, + ref: sourceRef('attachment-upload'), + }, + { + kind: 'image', + name: 'child.png', + mimeType: 'image/png', + bytes: 10, + ref: sourceRef('attachment-child', 'child-session'), + }, + ], + }, + { + type: 'tool_result', + id: 'result-1', + turnId: 'turn-1', + ts: 2, + toolUseId: 'tool-1', + content: { kind: 'image', mimeType: 'image/png', ref: sourceRef('attachment-tool-result') }, + } as StoredMessage, + ]; + const runtimeEvents: RuntimeEvent[] = [ + { + author: 'user', + content: { + kind: 'text', + text: 'evt', + attachments: [ + { + kind: 'image', + name: 'event.png', + mimeType: 'image/png', + bytes: 10, + ref: sourceRef('attachment-event'), + }, + ], + }, + } as RuntimeEvent, + { + author: 'tool', + content: { + kind: 'function_response', + id: 'fn-1', + name: 'Read', + result: { kind: 'image', mimeType: 'image/png', ref: sourceRef('attachment-fn') }, + }, + } as RuntimeEvent, + ]; + + const refs = collectConversationCopySessionFileRefs({ + sourceSessionId: 'session-source', + messages, + runtimeEvents, + archivedResults: [ + JSON.stringify({ + kind: 'image', + mimeType: 'image/png', + ref: sourceRef('attachment-archived'), + }), + // A child-Session archived image must be ignored. + JSON.stringify({ + kind: 'image', + mimeType: 'image/png', + ref: sourceRef('attachment-archived-child', 'child-session'), + }), + ], + }); + + assert.deepEqual([...refs].sort(), [ + 'attachment-archived', + 'attachment-event', + 'attachment-fn', + 'attachment-tool-result', + 'attachment-upload', + ]); +}); + test('Side Conversation preflight identifies linked-child archive bodies', () => { assert.equal( archivedToolResultContainsLinkedChildReferences( diff --git a/packages/runtime/src/conversation-copy.ts b/packages/runtime/src/conversation-copy.ts index 85867ee812..6a4054f81c 100644 --- a/packages/runtime/src/conversation-copy.ts +++ b/packages/runtime/src/conversation-copy.ts @@ -640,6 +640,59 @@ export function collectConversationCopyLinkedChildReferences(input: { return references; } +/** + * Collects the `relativePath` of every `session_file` StorageRef that the copied + * slice references and that belongs to the source Session. User-uploaded + * attachments carry `turnId === uploadId` (a sentinel, not a conversation turn), + * so the turn-scoped artifact copy never selects them; the coordinator feeds this + * set to `copyConversationArtifacts` as an explicit same-Session include list so + * their refs resolve in `rewriteStorageRef`. Walks exactly the ref sites reached + * by `rewriteStorageRef`: user-message attachments, tool_result image refs, text + * runtime-event attachments, and function_response / archived tool-result images. + */ +export function collectConversationCopySessionFileRefs(input: { + readonly sourceSessionId: string; + readonly messages: readonly StoredMessage[]; + readonly runtimeEvents: readonly RuntimeEvent[]; + readonly archivedResults: readonly string[]; +}): ReadonlySet { + const refs = new Set(); + const addRef = (ref: StorageRef): void => { + if (ref.kind === 'session_file' && ref.sessionId === input.sourceSessionId) { + refs.add(ref.relativePath); + } + }; + const addContent = (content: ToolResultContent): void => { + if (content.kind === 'image') addRef(content.ref); + }; + const addSerialized = (value: unknown): void => { + if (isArchivedToolResultPlaceholder(value)) return; + try { + addContent(decodePersistedToolResultContent(markPersisted(value))); + } catch { + // Opaque tool results carry no typed Session file reference. + } + }; + for (const message of input.messages) { + if (message.type === 'user' && message.attachments) { + for (const attachment of message.attachments) addRef(attachment.ref); + } else if (message.type === 'tool_result') { + addContent(message.content); + } + } + for (const event of input.runtimeEvents) { + if (event.content?.kind === 'text' && event.content.attachments) { + for (const attachment of event.content.attachments) addRef(attachment.ref); + } else if (event.content?.kind === 'function_response') { + addSerialized(event.content.result); + } + } + for (const serializedResult of input.archivedResults) { + addSerialized(deserializeToolResultArchive(serializedResult)); + } + return refs; +} + function cloneAgentRunEvent( event: AgentRunEvent, ids: { diff --git a/packages/storage/src/__tests__/artifact-store.test.ts b/packages/storage/src/__tests__/artifact-store.test.ts index 245a57980b..339c28d336 100644 --- a/packages/storage/src/__tests__/artifact-store.test.ts +++ b/packages/storage/src/__tests__/artifact-store.test.ts @@ -379,6 +379,54 @@ describe('SQLite Artifact store', () => { }); }); + test('includes explicit same-Session Artifacts outside the copied turns', 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', + }); + // A user upload carries the uploadId sentinel as its turnId, so it is + // never a member of the copied conversation turns. + const upload = await store.create({ + ...artifactInput('attachment-upload', 'uploaded bytes', 11), + turnId: 'upload-sentinel', + source: 'user_upload', + }); + + const withoutInclude = await store.copyConversationArtifacts({ + sourceSessionId: 'session-1', + targetSessionId: 'session-copy', + turnIds: ['turn-retained'], + }); + assert.equal(withoutInclude.artifactIds.has(upload.id), false); + + const withInclude = await store.copyConversationArtifacts({ + sourceSessionId: 'session-1', + targetSessionId: 'session-copy-2', + turnIds: ['turn-retained'], + includeArtifactIds: [upload.id], + }); + const copiedUploadId = withInclude.artifactIds.get(upload.id); + assert.ok(copiedUploadId); + assert.deepEqual(await store.readText(copiedUploadId), { + ok: true, + text: 'uploaded bytes', + }); + assert.equal((await store.get(copiedUploadId))?.sessionId, 'session-copy-2'); + // Unknown include ids are a no-op, not an error. + const withUnknown = await store.copyConversationArtifacts({ + sourceSessionId: 'session-1', + targetSessionId: 'session-copy-3', + turnIds: ['turn-retained'], + includeArtifactIds: ['does-not-exist'], + }); + assert.equal(withUnknown.artifactIds.has('does-not-exist'), false); + }); + }); + 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/artifact-store.ts b/packages/storage/src/artifact-store.ts index e26165a19a..814470c707 100644 --- a/packages/storage/src/artifact-store.ts +++ b/packages/storage/src/artifact-store.ts @@ -161,6 +161,14 @@ export interface ConversationArtifactCopyInput { readonly targetSessionId: string; readonly turnIds: readonly string[]; readonly excludeArtifactIds?: readonly string[]; + /** + * Source-Session artifact ids to copy in addition to the turn-scoped + * selection, regardless of their `turnId`. Used to carry user-uploaded + * attachments (whose `turnId` is the upload id sentinel, not a conversation + * turn) that the copied transcript still references. Lenient: an id with no + * matching source record is a no-op. + */ + readonly includeArtifactIds?: readonly string[]; readonly linkedArtifacts?: readonly { readonly sessionId: string; readonly artifactIds: readonly string[]; @@ -391,6 +399,7 @@ class SqliteArtifactStore implements ArtifactAuthorityStore { } const turnIds = new Set(input.turnIds); const excludedArtifactIds = new Set(input.excludeArtifactIds ?? []); + const includedArtifactIds = new Set(input.includeArtifactIds ?? []); for (const turnId of turnIds) assertArtifactTurnKey(turnId); const linkedArtifacts = input.linkedArtifacts ?? []; const requestedLinkedArtifactIds = new Map>(); @@ -428,6 +437,18 @@ class SqliteArtifactStore implements ArtifactAuthorityStore { selected.push({ ...record }); } } + const selectedIds = new Set(selected.map((record) => record.id)); + for (const record of this.records) { + if ( + record.sessionId === input.sourceSessionId && + includedArtifactIds.has(record.id) && + !excludedArtifactIds.has(record.id) && + !selectedIds.has(record.id) + ) { + selected.push({ ...record }); + selectedIds.add(record.id); + } + } return selected; }); diff --git a/packages/storage/src/artifact-stores.ts b/packages/storage/src/artifact-stores.ts index feaa5a94e2..99a5e1f232 100644 --- a/packages/storage/src/artifact-stores.ts +++ b/packages/storage/src/artifact-stores.ts @@ -155,6 +155,9 @@ function createWriterFacade( ...(input.excludeArtifactIds ? { excludeArtifactIds: Object.freeze([...input.excludeArtifactIds]) } : {}), + ...(input.includeArtifactIds + ? { includeArtifactIds: Object.freeze([...input.includeArtifactIds]) } + : {}), ...(input.linkedArtifacts ? { linkedArtifacts: Object.freeze(