Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 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
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
179 changes: 175 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 @@ -40,6 +40,7 @@ import {
WorkbarServicesProvider,
type CompanionQuoteSnapshot,
type StagedCompanionQuote,
type WorkbarIngestInput,
type WorkbarServices,
} from '../../renderer/features/workbar/testing.js';

Expand All @@ -57,6 +58,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 @@ -134,7 +140,7 @@ async function renderProbe(
modelChoices?: readonly ChatModelChoice[];
ready?: (container: Element) => boolean;
onSend?: (send: (text: string) => Promise<boolean>) => void;
onSteer?: (steer: (text: string) => Promise<boolean>) => void;
onSteer?: (steer: SteerFn) => void;
onStop?: (stop: () => Promise<void>) => void;
onSetPermissionMode?: (set: (mode: PermissionMode) => Promise<boolean>) => void;
confirmBypass?: () => Promise<boolean>;
Expand Down Expand Up @@ -199,7 +205,7 @@ async function renderOwnershipProbe(
} = {},
) {
let send!: (text: string) => Promise<boolean>;
let steer!: (text: string) => Promise<boolean>;
let steer!: SteerFn;
let stop!: () => Promise<void>;
let setPermissionMode!: (mode: PermissionMode) => Promise<boolean>;
let eventHandler: ((event: SessionEvent) => void) | undefined;
Expand Down Expand Up @@ -228,7 +234,8 @@ async function renderOwnershipProbe(
return {
...rendered,
send: (text: string) => send(text),
steer: (text: string) => steer(text),
steer: (text: string, attachmentItems?: WorkbarIngestInput[], onAdmitted?: () => void) =>
steer(text, attachmentItems, onAdmitted),
stop: () => stop(),
setPermissionMode: (mode: PermissionMode) => setPermissionMode(mode),
emit(event: SessionEvent) {
Expand Down Expand Up @@ -2012,7 +2019,7 @@ function QuoteCompanionProbe(props: {

function QuoteCompanionOwnershipProbe(props: {
onSend: (send: (text: string) => Promise<boolean>) => void;
onSteer?: (steer: (text: string) => Promise<boolean>) => void;
onSteer?: (steer: SteerFn) => void;
onStop?: (stop: () => Promise<void>) => void;
onSetPermissionMode?: (set: (mode: PermissionMode) => Promise<boolean>) => void;
onContextCompactionError?: (sessionId: string, error: unknown) => void;
Expand Down Expand Up @@ -2119,3 +2126,167 @@ 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 steerContents: Array<Parameters<WorkbarServices['sideChat']['steer']>[3]> = [];
const rendered = await renderOwnershipProbe(
{
send: async () => ({ ok: true as const, turnId: 'old-turn' }),
steer: async (_sessionId, _text, _admissionId, content) => {
steerContents.push(content);
return { kind: 'queued', messageId: 'steer-1' };
},
},
{
pendingQuotes: [{ id: 'quote-1', value: { text: 'streaming excerpt' } }],
},
);

await act(async () => {
assert.equal(await rendered.send('initial prompt'), true);
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(steerContents.length, 1);
assert.deepEqual(
steerContents[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' }),
steer: async (_sessionId, _text, admissionId) => {
const id = admissionId ?? '';
admissionIds.push(id);
// The reconnect/failure path answers without an admission receipt.
return { kind: 'outcome_unknown', messageId: id };
},
},
{ pendingQuotes: [] },
);

await act(async () => {
assert.equal(await rendered.send('initial prompt'), true);
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' }),
steer: async (_sessionId, _text, admissionId) => {
const id = admissionId ?? '';
admissionIds.push(id);
return { kind: 'outcome_unknown', messageId: id };
},
},
{ pendingQuotes: [] },
);

await act(async () => {
assert.equal(await rendered.send('initial prompt'), true);
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, []);
});
11 changes: 9 additions & 2 deletions apps/desktop/src/main/permission-response-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,14 @@ export function normalizeSessionSendCommand(input: unknown): NormalizedSendSessi
const displayText =
value.displayText === undefined ? undefined : normalizeSendText(value.displayText);
const skillIds = normalizeSessionSkillIds(value.skillIds);
if (!text.trim() && skillIds.length === 0) {
// A send may carry structured content instead of text (a pure quote or a
// pure attachment, #4804). Only the presence is decided here: attachment
// state, ownership, and size limits stay with the ingestion checks, and
// quotes are normalized below before the command is returned.
const quotes = normalizeOptionalQuotes(value.quotes).quotes;
const hasAttachmentItems =
Array.isArray(value.attachmentItems) && value.attachmentItems.length > 0;
if (!text.trim() && skillIds.length === 0 && (quotes?.length ?? 0) === 0 && !hasAttachmentItems) {
throw new Error('Invalid send text');
}
return {
Expand All @@ -225,7 +232,7 @@ export function normalizeSessionSendCommand(input: unknown): NormalizedSendSessi
? { turnOrchestration: normalizeTurnOrchestration(value.turnOrchestration) }
: {}),
...normalizeOptionalDirectoryReferences(value.directoryReferences),
...normalizeOptionalQuotes(value.quotes),
...(quotes !== undefined ? { quotes } : {}),
...normalizeOptionalWorkspaceFileReferences(
value.workspaceFileReferences,
displayText ?? text,
Expand Down
7 changes: 6 additions & 1 deletion apps/desktop/src/renderer/features/workbar/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,12 @@ export interface SideChatSessionPort {
sessionId: string,
target?: SideChatStopTarget,
): Promise<{ kind: 'retracted'; messageId: string } | undefined>;
steer(sessionId: string, text: string, admissionId?: string): Promise<SideChatSteerResult>;
steer(
sessionId: string,
text: string,
admissionId?: string,
content?: { quotes?: QuoteRef[]; attachmentItems?: WorkbarIngestInput[] },
): Promise<SideChatSteerResult>;
setPermissionMode(
sessionId: string,
mode: PermissionMode,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/renderer/features/workbar/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type {
WorkbarServices,
WorkbarSessionTracePage,
WorkbarSessionUsageSummary,
WorkbarIngestInput,
} from './ports.js';

export * from './model/workbar-tabs.js';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,21 @@ export function QuoteCompanionPanel(props: {
text,
streaming: companion.streaming,
compact: companion.compact,
steer: companion.steer,
steer: async (text) => {
// Submitted attachments retire on the confirmed-admission
// boundary, not on the hook's optimistic return: an unknown
// outcome keeps them staged for retry (#4804).
const submitted = pendingAttachments;
const submittedItems =
submitted.length > 0 ? toComposerIngestItems(submitted) : undefined;
return companion.steer(
text,
submittedItems,
submittedItems
? () => clearSubmittedAttachments(submitted)
: undefined,
);
},
send: async () => {
try {
preflightAttachmentItems(pendingAttachments);
Expand All @@ -358,16 +372,20 @@ export function QuoteCompanionPanel(props: {
);
return false;
}
// Same admission-boundary retirement as `steer` above.
const submitted = pendingAttachments;
const submittedItems =
submitted.length > 0 ? toComposerIngestItems(submitted) : undefined;
const accepted = await companion.send(
text,
pendingAttachments.length > 0
? toComposerIngestItems(pendingAttachments)
submittedItems,
submittedItems
? () => clearSubmittedAttachments(submitted)
: undefined,
);
if (accepted) {
props.onPromptAccepted?.(props.panelId, text);
}
if (accepted) clearSubmittedAttachments(pendingAttachments);
return accepted;
},
})
Expand All @@ -380,6 +398,8 @@ export function QuoteCompanionPanel(props: {
disabled={!companion.modelReady}
onPickAttachments={pickAttachments}
onAttachFilePaths={attachFilePaths}
// The side chat submits staged context without a prompt (#4804).
allowAttachmentOnlySend
pendingAttachments={pendingAttachments}
onRemoveAttachment={removeAttachment}
mentionSkills={mentions?.mentionSkills}
Expand Down
Loading