feat(queue): queue a message with everything the composer had attached

Queueing captured only the text and files. Context chips (inline comments,
terminal selections, browser annotations, PR comments and checks, quotes,
linked issue/PR/Linear references, pending synthetic parts) stayed in the
composer and only left with the next manual send, so a queued message the
server delivered went out without them and the chips rode an unrelated
message later.

A queued message now carries what the composer would have sent: the text
with its agent mention stripped and file mentions resolved into
attachments, the attached context as structured parts, and the skill
instruction derived from the text. The server delivers those parts in the
composer's order, the VS Code auto-send does the same, and editing a queued
message puts the chips and linked references back. A failed queue restores
the composer completely. Snapshots and broadcasts omit the captured
context like attachment payloads; a take returns it.

Claude-Session: https://claude.ai/code/session_01HB9wdLQoZX2vfyDjwv6Rso
This commit is contained in:
Bohdan Triapitsyn
2026-09-04 22:56:27 +03:00
parent ce5c0c068e
commit 34bf631157
17 changed files with 823 additions and 285 deletions
@@ -31,18 +31,30 @@ send never re-resolves mutable UI state:
{
id, createdAt,
content, // raw text for display and editing
text, // text to deliver (agent mention stripped); defaults to content
text, // text to deliver (agent mention stripped, file mentions resolved); defaults to content
agentMention?, // delivered as an `agent` part
attachments: [{ id, filename, mimeType, size, source, serverPath?, dataUrl }],
context: [ // what the composer had attached, in send order
{ kind: 'context', text, metadata, instructions? }, // a draft chip or linked issue/PR; metadata is the UI's structured payload
{ kind: 'instruction', text }, // derived from the text (skill instruction)
{ kind: 'synthetic', text }, // handed to the composer by another surface
],
sendConfig: { providerID, modelID, agent?, variant? } // required
}
```
The server is a courier for `context`: it validates the shape (a kind it
knows, a `metadata` object on `context` entries) and delivers each entry as a
synthetic text part, an entry's `instructions` going out as its own part just
before it and its `metadata` riding the part verbatim so the timeline renders
the context block back. The payload inside `metadata` is the UI's contract
(`lib/messages/contextParts.ts`), parsed by the UI on the way back.
`parseQueuedItemInput` rejects anything the server could not deliver later
(no text and no attachments, missing model, malformed attachment). Public
snapshots and broadcasts strip `dataUrl` from attachments — payloads can be
megabytes of base64 and must not ride every update; the only way to get them
back is a `take`.
(no text, attachments, or context; missing model; malformed attachment or
context entry). Public snapshots and broadcasts strip the payloads
attachment `dataUrl` (megabytes of base64) and `context` (a PR diff, say) —
so they do not ride every update; the only way to get them back is a `take`.
## Persistence
@@ -86,9 +98,10 @@ persisted "sending" flag would strand a message forever.
list (skills included) goes to `POST /session/:id/command` with the
captured model, agent, variant, and file parts;
- otherwise `POST /session/:id/prompt_async` with the parts in the same
order a UI send uses: text, files, pending project knowledge
(`sessionKnowledgeRuntime.resolvePendingForSession`, synthetic, recorded
as delivered only after the prompt is accepted), then the agent mention.
order a UI send uses: text, files, the captured context, pending project
knowledge (`sessionKnowledgeRuntime.resolvePendingForSession`, synthetic,
recorded as delivered only after the prompt is accepted), then the agent
mention. The command path sends files and captured context as `parts`.
Success removes the item, persists, broadcasts, and marks the user
message sent for notifications. Failure keeps the item, backs off
2 s → 60 s (doubling per consecutive failure of that item), and re-arms.
@@ -37,6 +37,10 @@ const FETCH_TIMEOUT_MS = 15_000;
const MESSAGE_TAIL_LIMIT = 2;
const ATTACHMENT_SOURCES = new Set(['local', 'server', 'vscode']);
// Context captured with a queued message (see QueuedContextPart in the UI
// store): attached context items carry metadata the timeline renders back;
// the other kinds are plain synthetic text.
const CONTEXT_PART_KINDS = new Set(['context', 'instruction', 'synthetic']);
const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{4,128}$/;
const getQueuedSendRetryDelayMs = (failures) =>
@@ -89,6 +93,21 @@ const parseAttachment = (value) => {
return attachment;
};
const parseContextPart = (value) => {
const raw = asRecord(value);
if (!raw || !CONTEXT_PART_KINDS.has(raw.kind)) return null;
const text = asText(raw.text);
if (raw.kind !== 'context') return { kind: raw.kind, text };
// The metadata is the UI's structured payload; the server only carries it
// to the prompt, so its shape is the UI's to validate on the way back.
const metadata = asRecord(raw.metadata);
if (!metadata) return null;
const part = { kind: 'context', text, metadata };
const instructions = asNonEmptyString(raw.instructions);
if (instructions) part.instructions = instructions;
return part;
};
/**
* Validates a queued item posted by a client. Throws a TypeError (→ 400) for
* anything that could not be delivered later: a queue must never hold an item
@@ -102,13 +121,18 @@ export const parseQueuedItemInput = (value) => {
const text = raw.text === undefined ? content : asText(raw.text);
const attachments = (asList(raw.attachments) ?? []).map(parseAttachment);
if (attachments.some((attachment) => attachment === null)) throw new TypeError('invalid attachment');
if (!text.trim() && attachments.length === 0) throw new TypeError('item needs text or attachments');
const context = (asList(raw.context) ?? []).map(parseContextPart);
if (context.some((part) => part === null)) throw new TypeError('invalid context part');
if (!text.trim() && attachments.length === 0 && context.length === 0) {
throw new TypeError('item needs text, attachments, or context');
}
const sendConfig = parseSendConfig(raw.sendConfig);
if (!sendConfig) throw new TypeError('item sendConfig with providerID and modelID is required');
const item = { content, text };
const agentMention = asNonEmptyString(raw.agentMention);
if (agentMention) item.agentMention = agentMention;
item.attachments = attachments;
item.context = context;
item.sendConfig = sendConfig;
return item;
};
@@ -126,10 +150,11 @@ const parseStoredItem = (value) => {
const toPublicAttachment = ({ dataUrl: _dataUrl, ...attachment }) => attachment;
// What clients see: everything except attachment payloads, which can be
// megabytes of base64 and would otherwise ride every broadcast.
// What clients see: everything except the payloads — attachment data URLs
// (megabytes of base64) and captured context (a PR diff, say) — which would
// otherwise ride every broadcast. A take hands the full item back.
const toPublicItem = (item) => {
const publicItem = { id: item.id, createdAt: item.createdAt, content: item.content };
const publicItem = { id: item.id, createdAt: item.createdAt, content: item.content, text: item.text };
if (item.agentMention) publicItem.agentMention = item.agentMention;
publicItem.attachments = item.attachments.map(toPublicAttachment);
publicItem.sendConfig = { ...item.sendConfig };
@@ -371,15 +396,29 @@ export function createMessageQueueRuntime({
url: attachment.dataUrl,
});
// Captured context is delivered the way the composer delivers it: one
// synthetic text part per entry, an attached item's metadata riding along
// and its reading instructions (a linked PR) going first.
const toContextParts = (part) => {
const synthetic = { type: 'text', text: part.text, synthetic: true };
if (part.kind !== 'context') return [synthetic];
synthetic.metadata = part.metadata;
return part.instructions
? [{ type: 'text', text: part.instructions, synthetic: true }, synthetic]
: [synthetic];
};
const sendItem = async (sessionId, directory, item) => {
const { providerID, modelID, agent, variant } = item.sendConfig;
const fileParts = item.attachments.map(toFilePart);
const contextParts = item.context.flatMap(toContextParts);
const command = await resolveSlashCommand(item.text, directory);
if (command) {
const body = { command: command.name, arguments: command.arguments, model: `${providerID}/${modelID}` };
if (agent) body.agent = agent;
if (variant) body.variant = variant;
if (fileParts.length > 0) body.parts = fileParts;
const extraParts = [...fileParts, ...contextParts];
if (extraParts.length > 0) body.parts = extraParts;
await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/command`, { directory, method: 'POST', body });
return;
}
@@ -390,11 +429,12 @@ export function createMessageQueueRuntime({
? await sessionKnowledgeRuntime.resolvePendingForSession(sessionId, directory)
.catch(() => ({ text: '', signature: '' }))
: { text: '', signature: '' };
// Same order as a UI send: the user's text and files, then the standing
// context, then the mentioned agent.
// Same order as a UI send: the user's text and files, the context queued
// with them, then the standing context, then the mentioned agent.
const parts = [];
if (item.text.trim()) parts.push({ type: 'text', text: item.text });
parts.push(...fileParts);
parts.push(...contextParts);
if (knowledge.text) parts.push({ type: 'text', text: knowledge.text, synthetic: true });
if (item.agentMention) parts.push({ type: 'agent', name: item.agentMention });
const body = { model: { providerID, modelID } };
@@ -109,9 +109,31 @@ describe('parseQueuedItemInput', () => {
text: 'hello',
agentMention: 'reviewer',
attachments: [],
context: [],
sendConfig: { providerID: 'anthropic', modelID: 'claude', agent: 'build' },
});
});
it('keeps captured context and rejects a malformed part', () => {
const context = [
{ kind: 'context', text: 'Comment on `a.ts`', metadata: { openchamberContext: { kind: 'code-comment' } }, instructions: '' },
{ kind: 'instruction', text: 'use the skill' },
{ kind: 'synthetic', text: 'conflict payload' },
];
expect(parseQueuedItemInput(item({ context })).context).toEqual([
{ kind: 'context', text: 'Comment on `a.ts`', metadata: { openchamberContext: { kind: 'code-comment' } } },
{ kind: 'instruction', text: 'use the skill' },
{ kind: 'synthetic', text: 'conflict payload' },
]);
expect(() => parseQueuedItemInput(item({ context: [{ kind: 'context', text: 'no metadata' }] }))).toThrow(TypeError);
expect(() => parseQueuedItemInput(item({ context: [{ kind: 'other', text: 'x' }] }))).toThrow(TypeError);
});
it('accepts an item that is only context', () => {
const parsed = parseQueuedItemInput(item({ content: '', text: '', context: [{ kind: 'synthetic', text: 'just context' }] }));
expect(parsed.text).toBe('');
expect(parsed.context).toHaveLength(1);
});
});
describe('message queue runtime', () => {
@@ -324,6 +346,64 @@ describe('message queue runtime', () => {
expect(openCode.state.sent[0].body).toEqual({ command: 'review', arguments: 'src', model: 'p/m', agent: 'build', variant: 'max' });
});
it('delivers captured context as synthetic parts, instructions first, before project knowledge', async () => {
const knowledge = {
resolvePendingForSession: async () => ({ text: 'pinned notes', signature: 'sig-1' }),
recordDelivered: async () => {},
};
const { runtime, openCode, emit } = createRuntime({ knowledge });
runtime.start();
const metadata = { openchamberContext: { kind: 'github-pr', number: 7, title: 'PR', url: 'https://x/pr/7' } };
await runtime.enqueue(SESSION, DIRECTORY, item({
agentMention: 'reviewer',
attachments: [{ id: 'a', filename: 'f.txt', mimeType: 'text/plain', size: 1, source: 'local', dataUrl: 'data:text/plain,hi' }],
context: [
{ kind: 'context', text: 'the diff', metadata, instructions: 'how to read it' },
{ kind: 'synthetic', text: 'conflict payload' },
{ kind: 'instruction', text: 'use the skill' },
],
}));
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
await settle();
expect(openCode.state.sent[0].body.parts).toEqual([
{ type: 'text', text: 'follow up' },
{ type: 'file', mime: 'text/plain', filename: 'f.txt', url: 'data:text/plain,hi' },
{ type: 'text', text: 'how to read it', synthetic: true },
{ type: 'text', text: 'the diff', synthetic: true, metadata },
{ type: 'text', text: 'conflict payload', synthetic: true },
{ type: 'text', text: 'use the skill', synthetic: true },
{ type: 'text', text: 'pinned notes', synthetic: true },
{ type: 'agent', name: 'reviewer' },
]);
});
it('sends captured context with a slash command too', async () => {
const { runtime, openCode, emit } = createRuntime();
runtime.start();
openCode.state.commands = [{ name: 'review' }];
await runtime.enqueue(SESSION, DIRECTORY, item({
content: '/review',
text: '/review',
context: [{ kind: 'synthetic', text: 'focus on tests' }],
}));
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
await settle();
expect(openCode.state.sent[0].path).toBe(`/session/${SESSION}/command`);
expect(openCode.state.sent[0].body.parts).toEqual([{ type: 'text', text: 'focus on tests', synthetic: true }]);
});
it('keeps captured context out of snapshots and broadcasts, and hands it back on take', async () => {
const { runtime, broadcasts } = createRuntime();
runtime.start();
const context = [{ kind: 'synthetic', text: 'a large diff' }];
const { itemId } = await runtime.enqueue(SESSION, DIRECTORY, item({ context }));
expect(runtime.sessionSnapshot(SESSION).items[0]).not.toHaveProperty('context');
expect(runtime.sessionSnapshot(SESSION).items[0].text).toBe('follow up');
expect(broadcasts.at(-1).properties.session.items[0]).not.toHaveProperty('context');
const taken = await runtime.take(SESSION, itemId);
expect(taken.item.context).toEqual(context);
});
it('attaches pending project knowledge and records its delivery', async () => {
const recorded = [];
const knowledge = {