Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
6bc5e62
fix(runtime-host): admit structured-only Messages and keep them model…
ggbdpq Sep 4, 2026
0f448f2
chore(runtime-host): declare the structured-only admission widening w…
ggbdpq Sep 5, 2026
09ec918
fix(runtime-host): read queued and steering messages back with the ad…
ggbdpq Sep 5, 2026
dcf339e
fix(runtime-host): bump compatibility epoch for message admission (#4…
ggbdpq Sep 7, 2026
fef4f35
fix(runtime-host): accept structured-only Messages at durable admission
ggbdpq Sep 7, 2026
54a454d
test(storage): pin quote-only and attachment-only durable admission
ggbdpq Sep 7, 2026
7bc4086
Merge remote-tracking branch 'upstream/main' into fix/structured-only…
ggbdpq Sep 8, 2026
b12ce2e
test(runtime): pin the quoted excerpt in the recap of a structured-on…
ggbdpq Sep 8, 2026
fa59e55
Merge remote-tracking branch 'upstream/main' into fix/structured-only…
ggbdpq Sep 8, 2026
2f68f36
fix(ui): carry the structured-only contract through side-chat consumers
ggbdpq Sep 8, 2026
7c45ad4
Merge branch 'main' into fix/structured-only-message-admission
ggbdpq Sep 8, 2026
11aa81e
fix(desktop): consume staged attachments only on confirmed admission
ggbdpq Sep 8, 2026
fc57e13
Merge branch 'main' into fix/structured-only-message-admission
ggbdpq Sep 8, 2026
816e6dc
fix(desktop): pass the renderer architecture check on the merged head
ggbdpq Sep 8, 2026
10064cf
Merge branch 'main' into fix/structured-only-message-admission
ggbdpq Sep 9, 2026
1c92ec6
Merge branch 'main' into fix/structured-only-message-admission
ggbdpq Sep 9, 2026
85ed3f5
fix(ui): keep message metadata on quote-only user messages
ggbdpq Sep 9, 2026
5fb25c0
fix(desktop): count retained attachments before rejecting an empty se…
ggbdpq Sep 9, 2026
79d4933
fix(runtime): replay steering history through the shared image materi…
ggbdpq Sep 9, 2026
14f0a1a
refactor(core,runtime): share one meaningful-content predicate across…
ggbdpq Sep 9, 2026
d53a202
style(runtime): apply biome formatting to the steering replay regression
ggbdpq Sep 9, 2026
0107eed
diagnostic(ui): revert the empty-text metadata render to bisect the r…
ggbdpq Sep 10, 2026
34a39ee
test(ui): pin the reverted metadata render for the rail bisection
ggbdpq Sep 10, 2026
26b9907
Merge branch 'main' into fix/structured-only-message-admission
ggbdpq Sep 10, 2026
8975c3d
Merge branch 'main' into fix/structured-only-message-admission
ggbdpq Sep 10, 2026
672ec7c
fix(ui,core): answer the review findings on the structured-only PR
ggbdpq Sep 10, 2026
bb8e35f
Merge branch 'main' into fix/structured-only-message-admission
ggbdpq Sep 10, 2026
14f56b2
Merge branch 'main' into fix/structured-only-message-admission
ggbdpq Sep 10, 2026
bd95907
wip: checkpoint before merging latest main
ggbdpq Sep 11, 2026
fe23e3c
Merge branch 'main' into fix/structured-only-message-admission
ggbdpq Sep 11, 2026
725c167
fix(core,ui,desktop): land the review follow-ups on the structured-on…
ggbdpq Sep 11, 2026
d434f54
chore: drop local scanner state from the tree
ggbdpq Sep 11, 2026
bddcd68
fix(desktop): pay for the attachment-only opt-in out of local token s…
ggbdpq Sep 11, 2026
e84d2ea
fix(desktop): reuse the shell copy variable to clear the token ratchet
ggbdpq Sep 11, 2026
5a0f8b3
chore(desktop): record the app-shell token figure after the opt-in la…
ggbdpq Sep 11, 2026
ebc16bd
chore: re-trigger CI
ggbdpq Sep 11, 2026
24d36f2
Merge branch 'main' into fix/structured-only-message-admission
ggbdpq Sep 11, 2026
54040d0
chore(desktop): record the merged app-shell token figure
ggbdpq Sep 11, 2026
3dff139
chore: re-trigger CI
ggbdpq Sep 11, 2026
e4158bd
bisect: revert the metadata render to isolate the Linux e2e failure
ggbdpq Sep 11, 2026
7d25207
ui: restore the quote-only metadata render after the bisect
ggbdpq Sep 11, 2026
520beb8
test(runtime-host): pin the quote-only round trip through a Host restart
ggbdpq Sep 11, 2026
ca9187c
Merge branch 'main' into fix/structured-only-message-admission
ggbdpq Sep 12, 2026
cec34fc
fix(desktop,runtime): close the three remaining #4815 review findings
ggbdpq Sep 12, 2026
dacaddb
Merge branch 'main' into fix/structured-only-message-admission
ggbdpq Sep 12, 2026
81f55d5
Merge branch 'main' into fix/structured-only-message-admission
ggbdpq Sep 12, 2026
01bdd8b
Merge branch 'main' into fix/structured-only-message-admission
ggbdpq Sep 12, 2026
5b353f8
test(desktop): feed the host-execution projection in the steer cases
ggbdpq Sep 12, 2026
e741465
Merge branch 'main' into fix/structured-only-message-admission
ggbdpq Sep 12, 2026
750cd3c
Merge remote-tracking branch 'upstream/main' into fix/structured-only…
ggbdpq Sep 12, 2026
301d62f
fix(desktop): accept the wire's inline base64 attachment carrier in t…
ggbdpq Sep 12, 2026
1c5e131
test(runtime-host,ui): pin the structured-only contract at the compos…
ggbdpq Sep 12, 2026
35747a2
refactor(runtime): drop the recap branch the shared predicate made an…
ggbdpq Sep 12, 2026
5f09841
fix(runtime): count directory references in the history-compact estim…
ggbdpq Sep 12, 2026
9646ac6
Merge remote-tracking branch 'upstream/main' into fix/structured-only…
ggbdpq Sep 12, 2026
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
2 changes: 1 addition & 1 deletion apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -856,7 +856,7 @@
"react": 1
},
"importSpecifiers": 104,
"nonTriviaTokens": 13402
"nonTriviaTokens": 13394
},
"src/renderer/use-app-shell-composer-quotes.ts": {
"importDeclarations": 2,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,15 @@ describe('permission response IPC boundary', () => {
{ type: 'send', text: '' },
{ type: 'send', text: 'x'.repeat(128_001) },
{ type: 'send', text: 'ok', retainedAttachments: [{ name: 'broken' }] },
// Junk attachment items must not satisfy the empty-body check
// (#4815 review reachability ③).
{ type: 'send', text: '', attachmentItems: [null] },
{ type: 'send', text: '', attachmentItems: [{}] },
{ type: 'send', text: '', attachmentItems: [{ approvalId: 7 }] },
{ type: 'send', text: 'hello', attachmentItems: 'notes.txt' },
// A raw File carrier never crosses the preload: it is encoded to inline
// base64 bytes before IPC, and main resolves only the encoded shapes.
{ type: 'send', text: 'hello', attachmentItems: [{ file: {} }] },
{ type: 'send', text: 'hello', turnId: 1 },
{ type: 'send', text: 'hello', skillIds: ['/bad'] },
{ type: 'send', text: 'hello', turnOrchestration: { mode: 'swarm', source: 'prompt' } },
Expand Down Expand Up @@ -286,6 +295,46 @@ describe('permission response IPC boundary', () => {
);
});

it('accepts a retained-attachment-only edit without inline text', () => {
// A normal edit can keep an existing attachment while dropping all inline
// text; the retained refs travel separately from attachmentItems and must
// count as content before the empty-body rejection (#4804).
const command = normalizeSessionSendCommand({
type: 'send',
text: ' ',
retainedAttachments: [
{
kind: 'image',
name: 'kept.png',
mimeType: 'image/png',
bytes: 12,
ref: {
kind: 'session_file',
sessionId: 'session-1',
relativePath: 'attachments/kept.png',
},
},
],
});
assert.equal(command?.retainedAttachments?.length, 1);
assert.equal(command?.retainedAttachments?.[0]?.name, 'kept.png');
});

it('accepts an inline base64 attachment as the only content', () => {
// Dragged/pasted blobs cross IPC as inline base64 bytes (the preload
// encodes the File before invoke), so an attachment-only send with no text
// is the #4804 shape at this boundary and must reach ingestion, which owns
// the byte-size and MIME checks.
const command = normalizeSessionSendCommand({
type: 'send',
text: '',
attachmentItems: [{ name: 'pasted.png', mimeType: 'image/png', base64: 'aGVsbG8=' }],
});
assert.deepEqual(command?.attachmentItems, [
{ name: 'pasted.png', mimeType: 'image/png', base64: 'aGVsbG8=' },
]);
});

it('accepts only the supported stop source', () => {
assert.deepEqual(normalizeStopSessionInput(undefined), {});
assert.deepEqual(
Expand Down
245 changes: 241 additions & 4 deletions apps/desktop/src/main/__tests__/quote-companion-retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
WorkbarServicesProvider,
type CompanionQuoteSnapshot,
type StagedCompanionQuote,
type WorkbarIngestInput,
type WorkbarServices,
} from '../../renderer/features/workbar/testing.js';

Expand All @@ -60,6 +61,11 @@ const originalGlobals = {
let mountedRoot: Root | undefined;
const SOURCE_SESSION = session('source-session');
type SideChatStopTarget = Parameters<WorkbarServices['sideChat']['stop']>[1];
type SteerFn = (
text: string,
attachmentItems?: WorkbarIngestInput[],
onAdmitted?: () => void,
) => Promise<boolean>;
type QueueUpdate = Extract<SessionEvent, { type: 'queue_update' }>;
type QueueEntry = NonNullable<QueueUpdate['steeringEntries']>[number];

Expand Down Expand Up @@ -127,7 +133,7 @@ async function renderProbe(
onSend?: (send: (text: string) => Promise<boolean>) => void;
onProjection?: (companion: ReturnType<typeof useQuoteCompanion>) => void;
onQueue?: (queue: (text: string) => Promise<boolean>) => void;
onSteer?: (steer: (text: string) => Promise<boolean>) => void;
onSteer?: (steer: SteerFn) => void;
onStop?: (stop: () => Promise<void>) => void;
onDeleteQueuedEntry?: (deleteEntry: (entryId: string) => Promise<void>) => void;
onSetPermissionMode?: (set: (mode: PermissionMode) => Promise<boolean>) => void;
Expand Down Expand Up @@ -198,7 +204,7 @@ async function renderOwnershipProbe(
let send!: (text: string) => Promise<boolean>;
let projection!: ReturnType<typeof useQuoteCompanion>;
let queue!: (text: string) => Promise<boolean>;
let steer!: (text: string) => Promise<boolean>;
let steer!: SteerFn;
let stop!: () => Promise<void>;
let deleteQueuedEntry!: (entryId: string) => Promise<void>;
let setPermissionMode!: (mode: PermissionMode) => Promise<boolean>;
Expand Down Expand Up @@ -238,7 +244,8 @@ async function renderOwnershipProbe(
...rendered,
send: (text: string) => send(text),
queue: (text: string) => queue(text),
steer: (text: string) => steer(text),
steer: (text: string, attachmentItems?: WorkbarIngestInput[], onAdmitted?: () => void) =>
steer(text, attachmentItems, onAdmitted),
stop: () => stop(),
deleteQueuedEntry: (entryId: string) => deleteQueuedEntry(entryId),
setPermissionMode: (mode: PermissionMode) => setPermissionMode(mode),
Expand Down Expand Up @@ -1988,6 +1995,66 @@ test('recovers the Host-edited Side Conversation steer from the queue projection
assert.equal(container.firstElementChild?.getAttribute('data-queue-texts'), '');
});

test('consumes a steered attachment when the started turn binds the admission', async () => {
const pendingSteer = deferred<{ kind: 'started'; turnId: string }>();
let admissionId: string | undefined;
let admitted = 0;
let steerPayload: { attachmentItems?: readonly WorkbarIngestInput[] } | undefined;
const attachmentItem: WorkbarIngestInput = { approvalId: 'approval-1', name: 'kept.png' };
const { container, emit, send, steer, hostTurn } = await renderOwnershipProbe({
send: async () => ({ ok: true as const, turnId: 'old-turn' }),
submitFollowUp: async (_sessionId, placement, _text, requestedAdmissionId, payload) => {
assert.equal(placement, 'current_turn');
admissionId = requestedAdmissionId;
steerPayload = payload;
return pendingSteer.promise;
},
});

await act(async () => {
assert.equal(await send('initial prompt'), true);
hostTurn('old-turn');
await Promise.resolve();
});
let steerResult!: Promise<boolean>;
await act(async () => {
steerResult = steer('steer with the kept image', [attachmentItem], () => {
admitted += 1;
});
await Promise.resolve();
});
await waitUntil(() => admissionId !== undefined);

await act(async () => {
pendingSteer.resolve({ kind: 'started', turnId: 'steer-started-turn' });
assert.equal(await steerResult, true);
await Promise.resolve();
});

// The attachments travel with the steering Message...
assert.deepEqual(steerPayload, { attachmentItems: [attachmentItem] });
assert.equal(
container.firstElementChild?.getAttribute('data-live-turn-id'),
'steer-started-turn',
);
// ...and binding the started turn IS the admission boundary: the consumer
// fires exactly once here, not on the later admission echo.
assert.equal(admitted, 1);

await act(async () => {
emit(
messageAdmittedEvent(
'late-admission-echo',
'steer-started-turn',
1,
admissionId as string,
),
);
await Promise.resolve();
});
assert.equal(admitted, 1, 'the admission echo must not consume a second time');
});

test('retracts a queued Side Conversation message without stopping the active turn', async () => {
let messageId: string | undefined;
const retracted: string[] = [];
Expand Down Expand Up @@ -3252,7 +3319,7 @@ function QuoteCompanionOwnershipProbe(props: {
onSend: (send: (text: string) => Promise<boolean>) => void;
onProjection?: (companion: ReturnType<typeof useQuoteCompanion>) => void;
onQueue?: (queue: (text: string) => Promise<boolean>) => void;
onSteer?: (steer: (text: string) => Promise<boolean>) => void;
onSteer?: (steer: SteerFn) => void;
onStop?: (stop: () => Promise<void>) => void;
onDeleteQueuedEntry?: (deleteEntry: (entryId: string) => Promise<void>) => void;
onSetPermissionMode?: (set: (mode: PermissionMode) => Promise<boolean>) => void;
Expand Down Expand Up @@ -3370,3 +3437,173 @@ async function awaitCompanion(container: Element, id = 'side-conversation'): Pro
async function awaitProcessing(container: Element): Promise<void> {
await waitUntil(() => container.firstElementChild?.getAttribute('data-processing') === 'true');
}

test('a structured-only send (empty text with a staged quote) reaches the fork admission', async () => {
const sendCommands: Array<Parameters<WorkbarServices['sideChat']['send']>[1]> = [];
const rendered = await renderOwnershipProbe(
{
listTurns: async () => [settledTurn('done-turn')],
branchFromTurn: async () => ({ ok: true as const, session: session('side-conversation') }),
send: async (_sessionId, command) => {
sendCommands.push(command);
return { ok: true as const, turnId: 'quote-only-turn' };
},
},
{
pendingQuotes: [{ id: 'quote-1', value: { text: 'selected excerpt' } }],
},
);
const probe = rendered.container.firstElementChild;
assert.ok(probe);

// The Composer enables Send once a quote is staged; an empty draft must ride
// the same admission as a text send instead of dying on the `!trimmed` guard.
await act(async () => {
assert.equal(await rendered.send(''), true);
await Promise.resolve();
});
await awaitCompanion(rendered.container);
assert.equal(sendCommands.length, 1);
assert.equal(sendCommands[0].text, '');
assert.deepEqual(
sendCommands[0].quotes?.map((quote) => quote.text),
['selected excerpt'],
);
assert.equal(probe.getAttribute('data-error'), '');
});

test('a structured-only steer (empty text with a staged quote) rides the steering contract', async () => {
const followUpContents: Array<Parameters<WorkbarServices['sideChat']['submitFollowUp']>[4]> = [];
const rendered = await renderOwnershipProbe(
{
send: async () => ({ ok: true as const, turnId: 'old-turn' }),
submitFollowUp: async (_sessionId, placement, _text, _admissionId, content) => {
assert.equal(placement, 'current_turn');
followUpContents.push(content);
return { kind: 'queued' as const };
},
},
{
pendingQuotes: [{ id: 'quote-1', value: { text: 'streaming excerpt' } }],
},
);

await act(async () => {
assert.equal(await rendered.send('initial prompt'), true);
rendered.hostTurn('old-turn');
await Promise.resolve();
});
await waitUntil(
() => rendered.container.firstElementChild?.getAttribute('data-streaming') === 'true',
);

// Streaming steers take the same structured-content contract: the quote alone
// is a valid steering Message, and the `!trimmed` guard must not drop it.
await act(async () => {
assert.equal(await rendered.steer(''), true);
await Promise.resolve();
});
assert.equal(followUpContents.length, 1);
assert.deepEqual(
followUpContents[0]?.quotes?.map((quote) => quote.text),
['streaming excerpt'],
);
});

test('a steer with staged attachments consumes them only on confirmed admission', async () => {
const admissionIds: string[] = [];
const rendered = await renderOwnershipProbe(
{
send: async () => ({ ok: true as const, turnId: 'old-turn' }),
submitFollowUp: async (_sessionId, placement, _text, admissionId) => {
assert.equal(placement, 'current_turn');
const id = admissionId ?? '';
admissionIds.push(id);
// The reconnect/failure path answers without an admission receipt.
return { kind: 'outcome_unknown' as const };
},
},
{ pendingQuotes: [] },
);

await act(async () => {
assert.equal(await rendered.send('initial prompt'), true);
rendered.hostTurn('old-turn');
await Promise.resolve();
});
await waitUntil(
() => rendered.container.firstElementChild?.getAttribute('data-streaming') === 'true',
);

const consumed: string[] = [];
await act(async () => {
assert.equal(
await rendered.steer('', [{ approvalId: 'a-1', name: 'notes.txt' }], () => {
consumed.push('admitted');
}),
true,
);
await Promise.resolve();
});
// The optimistic accept must not retire the attachments: with no admission
// receipt the Message may still be admitted or retracted by the Host.
assert.deepEqual(consumed, []);

// The late admission arrives through the fork's event stream; only now does
// the confirmed-admission boundary fire.
await act(async () => {
rendered.emit(messageAdmittedEvent('steer-late-admit', 'steered-turn', 1, admissionIds[0]));
});
assert.deepEqual(consumed, ['admitted']);
});

test('an unknown steer outcome that later retracts keeps the staged attachments', async () => {
const admissionIds: string[] = [];
const rendered = await renderOwnershipProbe(
{
send: async () => ({ ok: true as const, turnId: 'old-turn' }),
submitFollowUp: async (_sessionId, placement, _text, admissionId) => {
assert.equal(placement, 'current_turn');
const id = admissionId ?? '';
admissionIds.push(id);
return { kind: 'outcome_unknown' as const };
},
},
{ pendingQuotes: [] },
);

await act(async () => {
assert.equal(await rendered.send('initial prompt'), true);
rendered.hostTurn('old-turn');
await Promise.resolve();
});
await waitUntil(
() => rendered.container.firstElementChild?.getAttribute('data-streaming') === 'true',
);

const consumed: string[] = [];
await act(async () => {
assert.equal(
await rendered.steer('', [{ approvalId: 'a-1', name: 'notes.txt' }], () => {
consumed.push('admitted');
}),
true,
);
await Promise.resolve();
});
assert.deepEqual(consumed, []);

// A retraction releases the Message without consuming anything staged: the
// user keeps the attachments and may retry the steer.
await act(async () => {
rendered.emit({
type: 'message_admission',
id: 'steer-late-retract',
turnId: 'old-turn',
ts: 2,
messageId: admissionIds[0],
outcome: 'retracted',
});
});
assert.deepEqual(consumed, []);
});
Loading