Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -905,7 +908,7 @@ async function seedSource(
ref: {
kind: 'session_file',
sessionId: source.id,
relativePath: artifact.relativePath,
relativePath: artifact.id,
},
},
],
Expand Down Expand Up @@ -965,7 +968,7 @@ async function seedSource(
ref: {
kind: 'session_file',
sessionId: source.id,
relativePath: artifact.relativePath,
relativePath: artifact.id,
},
},
],
Expand Down Expand Up @@ -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<void> => {
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');
Expand All @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -1602,6 +1631,7 @@ async function verifyDurableBranch(
(message) => message.turnId !== 'active-source-turn',
),
);
await assertCopiedUpload(activeSourceSideConversationTargetId);
const sideConversationRuns = await execution.agentRunStore.listSessionRuns(
graphSideConversationTargetId,
);
Expand Down
10 changes: 10 additions & 0 deletions packages/runtime-host/src/server/session-revision-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
archivedToolResultContainsConversationOwnedReferences,
cloneConversationRuntimeLedger,
collectConversationCopyLinkedChildReferences,
collectConversationCopySessionFileRefs,
createConversationCopySlice,
prepareConversationRuntimeLedgerCopy,
type ConversationRuntimeLedgerCopyPlan,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()] }
: {}),
Expand Down
96 changes: 96 additions & 0 deletions packages/runtime/src/__tests__/conversation-copy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
archivedToolResultContainsConversationOwnedReferences,
cloneConversationRuntimeLedger,
collectConversationCopyLinkedChildReferences,
collectConversationCopySessionFileRefs,
createConversationCopySlice,
prepareConversationRuntimeLedgerCopy,
rewriteConversationCopyMessage,
Expand Down Expand Up @@ -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(
Expand Down
53 changes: 53 additions & 0 deletions packages/runtime/src/conversation-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
const refs = new Set<string>();
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<ToolResultContent>(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: {
Expand Down
48 changes: 48 additions & 0 deletions packages/storage/src/__tests__/artifact-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
21 changes: 21 additions & 0 deletions packages/storage/src/artifact-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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<string, Set<string>>();
Expand Down Expand Up @@ -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;
});

Expand Down
3 changes: 3 additions & 0 deletions packages/storage/src/artifact-stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down