Skip to content
Open
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
53 changes: 32 additions & 21 deletions apps/desktop/src/main/__tests__/app-shell-revision-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ function createActions(input: { messages: StoredMessage[]; failRefresh?: boolean
staged.clearedKeys.push(ownerKey);
return staged.quotes.splice(0, staged.quotes.length);
},
clearAttachments: (ownerKey: string) => {
staged.clearedKeys.push(ownerKey);
return [];
},
}),
},
openSessionInChat: (sessionId: string) => {
Expand Down Expand Up @@ -166,35 +170,40 @@ describe('app-shell revision actions with structured context (#5109)', () => {
assert.equal(h.composerState.text, 'plain follow-up');
});

it('refuses editing a message that carries attachments (#5274 review)', () => {
// Attachment ownership does not follow a revision copy — the copied
// transcript stops before the selected turn, so no target-owned refs
// exist client-side to restage. The edit refuses rather than silently
// dropping the files.
it('restages the attachments a message carries as retained pending items (#5109)', () => {
// The Host stages the edited turn's artifacts into the revision copy and
// hands back target-owned refs on the copy response, so the edit restages
// the attachment (source-owned until prepare swaps the ref) instead of
// refusing the edit outright.
const attachment = {
kind: 'image',
name: 'chart.png',
mimeType: 'image/png',
bytes: 10,
ref: { kind: 'session_file', sessionId: 'session-1', relativePath: 'a.png' },
};
const h = createActions({
messages: [
userMessage('turn-1', 'with image', {
attachments: [
{
kind: 'image',
name: 'chart.png',
mimeType: 'image/png',
bytes: 10,
ref: { kind: 'session_file', sessionId: 'session-1', relativePath: 'a.png' },
},
],
}),
userMessage('turn-1', 'with image', { attachments: [attachment] }),
],
});

h.beginEditUserMessage('turn-1');

assert.equal(
h.drafts.at(-1),
undefined,
'a revision copy excludes the revised turn, so no target-owned attachment rewrite exists to restage',
const draft = h.drafts.at(-1) as { originalAttachments?: unknown[] } | undefined;
assert.ok(draft, 'a carrying message is editable now');
assert.deepEqual(draft?.originalAttachments, [attachment]);
assert.deepEqual(
h.composerState.attachments.map((item) => (item as { displayName?: string }).displayName),
['chart.png'],
'the source attachment restages into the composer plates',
);
assert.deepEqual(
(h.composerState.attachments[0] as { source?: unknown } | undefined)?.source,
{ type: 'retained', attachment },
'the pending item carries the source-owned ref until prepare',
);
assert.equal(h.composerState.text, '', 'the composer stays untouched');
assert.equal(h.composerState.text, 'with image');
});

it('stages a source message quotes into the composer', () => {
Expand Down Expand Up @@ -329,6 +338,8 @@ describe('revision draft lifecycle over a prepared send', () => {
attachments: [],
restoreQuotes: (_ownerKey: string, _quotes: unknown[]) => {},
clearQuotes: (_ownerKey: string) => [],
restoreAttachments: (_ownerKey: string, _attachments: unknown[]) => {},
clearAttachments: (_ownerKey: string) => [],
}),
},
openSessionInChat: (sessionId: string) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,8 @@ async function mountRevisionWorld(): Promise<RevisionWorld> {
attachments: [],
restoreQuotes: () => {},
clearQuotes: () => [],
restoreAttachments: () => {},
clearAttachments: () => [],
}),
},
openSessionInChat: (sessionId: string) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ test('the in-flight revision send still reads the re-keyed plate (#5274 review)'
attachments: [],
restoreQuotes: surface.restoreQuotes,
clearQuotes: surface.clearQuotes,
restoreAttachments: () => {},
clearAttachments: () => [],
}),
},
openSessionInChat: (sessionId: string) => {
Expand Down
7 changes: 5 additions & 2 deletions apps/desktop/src/main/runtime-host-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1245,7 +1245,7 @@ export class DesktopRuntimeHostClient {
async copySession(
kind: "branch" | "revision",
input: Omit<SessionConversationCopyInput, "expectedSourceRevision">,
): Promise<SessionCatalogProjection> {
): Promise<SessionCatalogProjection & { stagedAttachments?: readonly AttachmentRef[] }> {
for (let attempt = 0; attempt < MAX_OPTIMISTIC_ATTEMPTS; attempt += 1) {
const source = await this.#requireSession(input.sourceSessionId);
const request = { ...input, expectedSourceRevision: source.revision };
Expand All @@ -1254,7 +1254,10 @@ export class DesktopRuntimeHostClient {
? await this.request("session.branch.create", request)
: await this.request("session.revision.create", request);
if (result.kind === "committed")
return requireSessionProjection(result.session);
return {
...requireSessionProjection(result.session),
...(result.stagedAttachments ? { stagedAttachments: result.stagedAttachments } : {}),
};
}
throw revisionConflict(`${kind} copy`, input.sourceSessionId);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -874,7 +874,10 @@ export function registerRuntimeHostSessionExecutionIpc(
sourceTurnId: normalized.sourceTurnId,
});
deps.emitSessionsChanged("created", revision.id);
return toDesktopHostSessionSummary(revision);
return {
...toDesktopHostSessionSummary(revision),
...(revision.stagedAttachments ? { stagedAttachments: revision.stagedAttachments } : {}),
};
},
);
return async (sessionId) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ export function useComposerAttachments(options: Omit<Parameters<typeof useShared
attachments: attachments.submittableAttachments ?? [],
restoreQuotes: quotes.restoreQuotes,
clearQuotes: quotes.clearQuotes,
restoreAttachments: attachments.restorePendingAttachments,
clearAttachments: attachments.clearAttachmentsAt,
});
return { ...attachments, ...quotes, restoreQueuedDraftContext, stagedContext,
captureContext: mutations.capture, markDraftChanged: mutations.changed };
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/shared/desktop-session-projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ export interface DesktopSessionSummary extends SessionSummary {
readonly profileKind: RuntimeHostProfileKind;
/** Present only for Session projections granted to a Guest principal. */
readonly shared?: true;
/** Revision copies only (#5109): the edited turn's attachments restaged into
* the copy with target-owned refs, for the composer's re-attach. */
readonly stagedAttachments?: readonly AttachmentRef[];
}

export type DesktopSessionSummaryInput = SessionSummary & { readonly revision: number; readonly localState?: 'pending' | 'cached'; readonly localCreatedAt?: number };
Expand Down
30 changes: 28 additions & 2 deletions packages/core/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,15 @@ export interface SessionConversationCopy {
requestFingerprint: `sha256:${string}`;
state: 'preparing' | 'committed';
intent?: 'side_conversation';
/** Revision copies (#5109): where each staged attachment of the edited turn
* landed in the target Session — source Artifact id to target record id.
* Durable so a retried copy request can rebuild its response with read-only
* lookups instead of re-running artifact writes (which would republish
* artifacts another client has since deleted). */
stagedArtifacts?: readonly {
readonly sourceId: string;
readonly targetId: string;
}[];
}

export type SubagentSessionRuntimeSummary = Omit<
Expand Down Expand Up @@ -557,7 +566,7 @@ const SUBAGENT_SESSION_SPAWN_IDENTITY_SHAPE = defineObjectShape<SubagentSessionS
);
const SESSION_CONVERSATION_COPY_SHAPE = defineObjectShape<SessionConversationCopy>()(
['kind', 'sourceSessionId', 'requestFingerprint', 'state'],
['sourceTurnId', 'intent'],
['sourceTurnId', 'intent', 'stagedArtifacts'],
);
const SESSION_LINEAGE_ID_MAX_CHARS = 512;
const SESSION_LINEAGE_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
Expand Down Expand Up @@ -655,7 +664,24 @@ export function isSessionConversationCopy(value: unknown): value is SessionConve
: isSessionLineageId(value.sourceTurnId)) &&
typeof value.requestFingerprint === 'string' &&
/^sha256:[0-9a-f]{64}$/.test(value.requestFingerprint) &&
(value.state === 'preparing' || value.state === 'committed')
(value.state === 'preparing' || value.state === 'committed') &&
isStagedArtifactIdPairs(value.stagedArtifacts)
);
}

/** `stagedArtifacts` entries (#5109): both ids are Artifact record ids. */
function isStagedArtifactIdPairs(value: unknown): boolean {
return (
value === undefined ||
(Array.isArray(value) &&
value.every(
(entry) =>
isRecord(entry) &&
typeof entry.sourceId === 'string' &&
entry.sourceId.length > 0 &&
typeof entry.targetId === 'string' &&
entry.targetId.length > 0,
))
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,74 @@ describe('Session revision protocol', () => {
);
});

test('carries the edited turn staged attachments on a committed revision', () => {
const attachment = {
kind: 'code',
name: 'source.txt',
mimeType: 'text/plain',
bytes: 14,
ref: { kind: 'session_file', sessionId: 'revision-target', relativePath: 'artifacts/x' },
};
assert.deepEqual(
decodeHostFrame({
requestId: 'request-staged',
operation: 'session.revision.create',
ok: true,
result: {
kind: 'committed',
session: sessionProjection('target-session'),
stagedAttachments: [attachment],
},
}),
{
requestId: 'request-staged',
operation: 'session.revision.create',
ok: true,
result: {
kind: 'committed',
session: sessionProjection('target-session'),
stagedAttachments: [attachment],
},
},
);
// Optional: a committed revision without staged attachments decodes bare.
assert.deepEqual(
decodeHostFrame({
requestId: 'request-bare',
operation: 'session.revision.create',
ok: true,
result: {
kind: 'committed',
session: sessionProjection('target-session'),
},
}),
{
requestId: 'request-bare',
operation: 'session.revision.create',
ok: true,
result: {
kind: 'committed',
session: sessionProjection('target-session'),
},
},
);
// A malformed attachment shape is rejected, not silently dropped.
assert.throws(
() =>
decodeHostFrame({
requestId: 'request-bad',
operation: 'session.revision.create',
ok: true,
result: {
kind: 'committed',
session: sessionProjection('target-session'),
stagedAttachments: [{ kind: 'code', name: 'x' }],
},
}),
isInvalidFrame,
);
});

test('keeps single-target Revision abandonment identity exact', () => {
const frame = decodeClientFrame({
requestId: 'request-abandon',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,84 @@ async function verifyConcurrentRevisionAuthority(
);

const sourceAfterRevision = await querySession(tui, sourceSessionId);
// A revision of the upload turn itself (#5109): the edited turn's own
// attachment is a user upload whose ref names the Artifact record id, which
// the copy reports under `artifactIds`, not `relativePaths` — the committed
// response must still stage the rewritten ref (#6246 review).
const uploadedTurnRevision = await desktop.request('session.revision.create', {
sourceSessionId,
targetSessionId: 'staged-upload-revision-target',
sourceTurnId: 'turn-1',
expectedSourceRevision: sourceAfterRevision.revision,
});
assert.equal(uploadedTurnRevision.kind, 'committed');
if (uploadedTurnRevision.kind !== 'committed') assert.fail('Revision must commit');
assert.equal(uploadedTurnRevision.stagedAttachments?.length, 1);
const stagedUpload = uploadedTurnRevision.stagedAttachments?.[0];
assert.equal(stagedUpload?.name, 'source.txt');
assert.equal(stagedUpload?.ref.kind, 'session_file');
if (stagedUpload?.ref.kind !== 'session_file')
assert.fail('Staged ref must stay session-backed');
assert.equal(stagedUpload.ref.sessionId, 'staged-upload-revision-target');
assert.notEqual(stagedUpload.ref.relativePath, 'source-artifact');
assert.deepEqual(
await desktop.request('artifact.query', {
kind: 'read_text',
sessionId: 'staged-upload-revision-target',
artifactId: stagedUpload.ref.relativePath,
}),
{
kind: 'text',
sessionId: 'staged-upload-revision-target',
artifactId: stagedUpload.ref.relativePath,
preview: { ok: true, text: 'retained bytes' },
},
);
// A retried request whose target already committed resolves through the
// committed-probe path: it must answer with the same staged attachments
// the first attempt returned, not a bare committed response (#6246
// review).
const uploadedTurnRevisionRetry = await desktop.request('session.revision.create', {
sourceSessionId,
targetSessionId: 'staged-upload-revision-target',
sourceTurnId: 'turn-1',
expectedSourceRevision: sourceAfterRevision.revision,
});
assert.equal(uploadedTurnRevisionRetry.kind, 'committed');
if (uploadedTurnRevisionRetry.kind !== 'committed') assert.fail('Retry must stay committed');
assert.equal(uploadedTurnRevisionRetry.stagedAttachments?.length, 1);
const retriedStaged = uploadedTurnRevisionRetry.stagedAttachments?.[0];
assert.equal(retriedStaged?.name, 'source.txt');
assert.equal(retriedStaged?.ref.kind, 'session_file');
if (retriedStaged?.ref.kind !== 'session_file')
assert.fail('Retried staged ref must stay session-backed');
assert.equal(retriedStaged.ref.sessionId, 'staged-upload-revision-target');
// A staged attachment deleted from the target after the commit stays
// deleted: the replay is read-only and must not republish it (#6246
// review).
await desktop.request('artifact.delete', {
sessionId: 'staged-upload-revision-target',
artifactId: retriedStaged.ref.relativePath,
});
const afterDelete = await desktop.request('session.revision.create', {
sourceSessionId,
targetSessionId: 'staged-upload-revision-target',
sourceTurnId: 'turn-1',
expectedSourceRevision: sourceAfterRevision.revision,
});
assert.equal(afterDelete.kind, 'committed');
if (afterDelete.kind !== 'committed') assert.fail('Replay must stay committed');
assert.equal(
afterDelete.stagedAttachments,
undefined,
'a deleted staged attachment drops off the replay instead of being republished',
);
assert.deepEqual(
await desktop.request('session.revision.abandon', {
targetSessionId: 'staged-upload-revision-target',
}),
{ kind: 'abandoned', sessionId: 'staged-upload-revision-target' },
);
const staleExpectedRevision = sourceAfterRevision.revision + 1;
const stale = await tui.request('session.revision.create', {
...revisionInput,
Expand Down
5 changes: 4 additions & 1 deletion packages/runtime-host/src/protocol/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,10 @@ 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.
// Re-check against main before merging; sibling PRs may use the same next epoch.
export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 229 as const;
export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 230 as const;
// 230: Revision copy responses carry stagedAttachments — the edited turn's
// attachments restaged into the copy with target-owned refs (#5109). Older
// peers reject the additional field in the strict copy-result decoder.
// 229: Session check-in proposes cards for unfinished work across Sessions.
// 228: Executor catalog queries support per-agent filtering and process-free inventory.
// Older peers reject the executorId and listOnly fields in the strict query decoder.
Expand Down
Loading