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:
@@ -3,11 +3,11 @@ import { ComposerDictation } from '@/components/dictation/ComposerDictation';
|
||||
// sessionStore removed — currentSessionId comes from useSessionUIStore
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { createMessageQueueTarget, getMessageQueueKey, useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
|
||||
import { createMessageQueueTarget, getMessageQueueKey, useMessageQueueStore, type QueuedContextPart, type QueuedMessage } from '@/stores/messageQueueStore';
|
||||
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSelectionStore } from '@/sync/selection-store';
|
||||
import { prepareLocalAttachments, useInputStore } from '@/sync/input-store';
|
||||
import { prepareLocalAttachments, useInputStore, type SyntheticContextPart } from '@/sync/input-store';
|
||||
import {
|
||||
ACCEPTED_ATTACHMENT_EXTENSIONS,
|
||||
ATTACHMENT_ACCEPT,
|
||||
@@ -49,6 +49,7 @@ import type { SnippetAutocompleteHandle } from './SnippetAutocomplete';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ModelControls } from './ModelControls';
|
||||
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
||||
import { CONTEXT_METADATA_KEY, draftFromContextPayload } from '@/lib/messages/contextParts';
|
||||
import { ComposerStatusBar } from './ComposerStatusBar';
|
||||
import { PendingChangesBar } from './PendingChangesBar';
|
||||
import { useChatColumnSession } from './chatColumnSession';
|
||||
@@ -138,7 +139,7 @@ import {
|
||||
toProjectRelativeMentionPath,
|
||||
toServerFileUrl,
|
||||
} from './composer/attachments/filePaths';
|
||||
import { buildOutgoingMessage } from './composer/submit/buildOutgoingMessage';
|
||||
import { buildComposerContext, buildOutgoingMessage } from './composer/submit/buildOutgoingMessage';
|
||||
import {
|
||||
buildCommandVariables,
|
||||
canRunCommand,
|
||||
@@ -215,6 +216,70 @@ const buildSkillMentionInstruction = (skillNames: string[]): string | null => {
|
||||
return `The user explicitly mentioned these skills in their message: ${formatted}. Use the corresponding skill tool when it is relevant to accomplishing the user's request.`;
|
||||
};
|
||||
|
||||
type LinkedReferenceAuthor = { login: string; avatarUrl?: string };
|
||||
type LinkedGitHubIssue = { number: number; title: string; url: string; contextText: string; author?: LinkedReferenceAuthor };
|
||||
type LinkedGitHubPr = {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
head: string;
|
||||
base: string;
|
||||
includeDiff: boolean;
|
||||
instructionsText: string;
|
||||
contextText: string;
|
||||
author?: LinkedReferenceAuthor;
|
||||
};
|
||||
type LinkedLinearIssueRef = { identifier: string; title: string; url: string; contextText: string; author?: LinkedReferenceAuthor };
|
||||
type LinkedReferences = { issue: LinkedGitHubIssue | null; pr: LinkedGitHubPr | null; linear: LinkedLinearIssueRef | null };
|
||||
|
||||
/**
|
||||
* Record what a session was pointed at, so the work-status panel can show it
|
||||
* as a context source long after the message scrolled away. A snapshot only —
|
||||
* never re-fetched, never authoritative. Failures are swallowed: the message
|
||||
* went out (or was queued), and a missing bookkeeping entry must not surface
|
||||
* as an error.
|
||||
*/
|
||||
const recordLinkedReferences = (
|
||||
sessionId: string,
|
||||
directory: Parameters<typeof sessionActions.setLinkedIssue>[1],
|
||||
refs: LinkedReferences,
|
||||
) => {
|
||||
const attachedThread = refs.issue
|
||||
? { attachment: refs.issue, kind: 'issue' as const }
|
||||
: refs.pr
|
||||
? { attachment: refs.pr, kind: 'pull' as const }
|
||||
: null;
|
||||
if (attachedThread) {
|
||||
void sessionActions.setLinkedIssue(
|
||||
sessionId,
|
||||
directory,
|
||||
buildLinkedIssue({
|
||||
url: attachedThread.attachment.url,
|
||||
number: attachedThread.attachment.number,
|
||||
title: attachedThread.attachment.title,
|
||||
kind: attachedThread.kind,
|
||||
author: attachedThread.attachment.author,
|
||||
linkedAt: Date.now(),
|
||||
}),
|
||||
true,
|
||||
).catch(() => undefined);
|
||||
}
|
||||
if (refs.linear) {
|
||||
void sessionActions.setLinkedIssue(
|
||||
sessionId,
|
||||
directory,
|
||||
buildLinkedLinearIssue({
|
||||
identifier: refs.linear.identifier,
|
||||
title: refs.linear.title,
|
||||
url: refs.linear.url,
|
||||
author: refs.linear.author,
|
||||
linkedAt: Date.now(),
|
||||
}),
|
||||
true,
|
||||
).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const hasUserMessages = (sessionId: string, directory?: string) => {
|
||||
return getSyncMessages(sessionId, directory).some((message) => message.role === 'user');
|
||||
};
|
||||
@@ -725,37 +790,61 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
attachments,
|
||||
};
|
||||
}, [resolveInlineFileMention]);
|
||||
|
||||
type DocumentMentionPreparation =
|
||||
| { status: 'ready'; prepared: Map<string, AttachedFile[]> }
|
||||
| { status: 'failed'; filename: string }
|
||||
| { status: 'runtime-changed' };
|
||||
|
||||
/**
|
||||
* Document mentions (`@notes.pdf`) are sent as converted attachments. Their
|
||||
* sources are fetched up front — by the send, or by queueing, since the
|
||||
* server that later delivers a queued message cannot read them.
|
||||
*/
|
||||
const prepareDocumentMentions = React.useCallback(async (
|
||||
texts: readonly string[],
|
||||
reservedFilenames: Set<string>,
|
||||
runtimeKey: string,
|
||||
): Promise<DocumentMentionPreparation> => {
|
||||
const prepared = new Map<string, AttachedFile[]>();
|
||||
for (const rawText of texts) {
|
||||
for (const token of scanMentions(rawText)) {
|
||||
const mention = resolveInlineFileMention(token.name);
|
||||
if (
|
||||
!mention
|
||||
|| !isDocumentAttachmentFilename(mention.filename)
|
||||
|| prepared.has(mention.serverPath)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const response = await runtimeFetch('/api/fs/raw', { query: { path: mention.serverPath } });
|
||||
if (!response.ok) throw new Error(`Failed to read ${mention.filename}`);
|
||||
const sourceBlob = await response.blob();
|
||||
if (getRuntimeKey() !== runtimeKey) return { status: 'runtime-changed' };
|
||||
const source = new File([sourceBlob], mention.filename);
|
||||
const converted = await prepareLocalAttachments(source, reservedFilenames);
|
||||
if (!converted || converted.length === 0) throw new Error(`Failed to prepare ${mention.filename}`);
|
||||
if (getRuntimeKey() !== runtimeKey) return { status: 'runtime-changed' };
|
||||
prepared.set(mention.serverPath, converted);
|
||||
for (const attachment of converted) reservedFilenames.add(attachment.filename);
|
||||
} catch {
|
||||
if (getRuntimeKey() !== runtimeKey) return { status: 'runtime-changed' };
|
||||
return { status: 'failed', filename: mention.filename };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { status: 'ready', prepared };
|
||||
}, [resolveInlineFileMention]);
|
||||
const prevWasAbortedRef = React.useRef(false);
|
||||
|
||||
// Issue linking state
|
||||
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
|
||||
const [prPickerOpen, setPrPickerOpen] = React.useState(false);
|
||||
const [linearPickerOpen, setLinearPickerOpen] = React.useState(false);
|
||||
const [linkedIssue, setLinkedIssue] = React.useState<{
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
contextText: string;
|
||||
author?: { login: string; avatarUrl?: string };
|
||||
} | null>(null);
|
||||
const [linkedPr, setLinkedPr] = React.useState<{
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
head: string;
|
||||
base: string;
|
||||
includeDiff: boolean;
|
||||
instructionsText: string;
|
||||
contextText: string;
|
||||
author?: { login: string; avatarUrl?: string };
|
||||
} | null>(null);
|
||||
const [linkedLinearIssue, setLinkedLinearIssue] = React.useState<{
|
||||
identifier: string;
|
||||
title: string;
|
||||
url: string;
|
||||
contextText: string;
|
||||
author?: { login: string; avatarUrl?: string };
|
||||
} | null>(null);
|
||||
const [linkedIssue, setLinkedIssue] = React.useState<LinkedGitHubIssue | null>(null);
|
||||
const [linkedPr, setLinkedPr] = React.useState<LinkedGitHubPr | null>(null);
|
||||
const [linkedLinearIssue, setLinkedLinearIssue] = React.useState<LinkedLinearIssueRef | null>(null);
|
||||
|
||||
// Message queue
|
||||
const messageQueueTarget = currentSessionId
|
||||
@@ -919,45 +1008,56 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
const handleSubmitRef = React.useRef<(options?: SubmitOptions) => Promise<void>>(async () => {});
|
||||
|
||||
// Add message to queue instead of sending
|
||||
const handleQueueMessage = React.useCallback(() => {
|
||||
const handleQueueMessage = React.useCallback(async () => {
|
||||
const inputSnapshot = getCurrentInputSnapshot();
|
||||
if (!inputSnapshot.hasContent || !currentSessionId || !messageQueueTarget) return;
|
||||
|
||||
// Context drafts stay in their store: the send that later delivers the
|
||||
// queue consumes them and attaches them as structured context parts.
|
||||
const queueRuntimeKey = getRuntimeKey();
|
||||
const queueTarget = messageQueueTarget;
|
||||
const queueSessionId = currentSessionId;
|
||||
const messageToQueue = inputSnapshot.message.replace(/^\n+|\n+$/g, '');
|
||||
const attachmentsToQueue = sanitizeAttachmentsForSend(attachedFiles);
|
||||
// Resolved now, not at delivery: the server that sends a queued
|
||||
// message has no agent list, and the mention must match what was
|
||||
// visible when the user typed it.
|
||||
const { sanitizedText, mention } = parseAgentMentions(messageToQueue, agents);
|
||||
const composerAttachments = sanitizeAttachmentsForSend(attachedFiles);
|
||||
|
||||
addToQueue(messageQueueTarget, {
|
||||
content: messageToQueue,
|
||||
text: sanitizedText,
|
||||
agentMention: mention?.name,
|
||||
attachments: attachmentsToQueue.length > 0 ? attachmentsToQueue : undefined,
|
||||
sendConfig: currentProviderId && currentModelId ? {
|
||||
providerID: currentProviderId,
|
||||
modelID: currentModelId,
|
||||
agent: currentAgentName ?? undefined,
|
||||
variant: currentVariant ?? undefined,
|
||||
} : undefined,
|
||||
}).catch((error) => {
|
||||
console.warn('[queue] failed to queue message:', error);
|
||||
toast.error(t('chat.queuedMessage.toast.queueFailed'));
|
||||
// The composer was cleared on queueing; give the text back unless
|
||||
// the user has already typed something new.
|
||||
const currentInput = composerRef.current?.getValue() ?? messageRef.current;
|
||||
if (!currentInput) {
|
||||
setMessage(messageToQueue);
|
||||
} else {
|
||||
useInputStore.getState().setPendingInputText(messageToQueue, 'append');
|
||||
}
|
||||
if (attachmentsToQueue.length > 0) {
|
||||
useInputStore.getState().setAttachedFiles([...useInputStore.getState().attachedFiles, ...attachmentsToQueue]);
|
||||
}
|
||||
});
|
||||
// A queued message is resolved now, not at delivery: the server that
|
||||
// sends it has no agent list, no confirmed mentions, and no way to read
|
||||
// a document the user named — and the mention must match what was
|
||||
// visible when the user typed it.
|
||||
const documentMentions = await prepareDocumentMentions(
|
||||
[messageToQueue],
|
||||
new Set(composerAttachments.map((attachment) => attachment.filename)),
|
||||
queueRuntimeKey,
|
||||
);
|
||||
if (documentMentions.status === 'runtime-changed') return;
|
||||
if (documentMentions.status === 'failed') {
|
||||
toast.error(t('chat.chatInput.toast.attachNamedFailed', { name: documentMentions.filename }));
|
||||
return;
|
||||
}
|
||||
const { sanitizedText, mention } = parseAgentMentions(messageToQueue, agents);
|
||||
const { attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText, documentMentions.prepared);
|
||||
const availableSkillNames = new Set(
|
||||
selectSkillsForDirectory(useSkillsStore.getState(), currentDirectory).map((skill) => skill.name),
|
||||
);
|
||||
const skillInstruction = buildSkillMentionInstruction(collectInlineSkillMentions(sanitizedText, availableSkillNames));
|
||||
|
||||
// Everything attached to the composer leaves with the message: the
|
||||
// chips are part of what was queued, and come back if it is edited.
|
||||
const syntheticParts = consumePendingSyntheticParts() ?? [];
|
||||
const draftTarget = inlineDraftTarget;
|
||||
const drafts = draftTarget ? consumeDrafts(draftTarget) : [];
|
||||
const linked: LinkedReferences = { issue: linkedIssue, pr: linkedPr, linear: linkedLinearIssue };
|
||||
const context = buildComposerContext({
|
||||
inlineComments: drafts,
|
||||
syntheticTexts: syntheticParts.map((part) => part.text),
|
||||
linkedIssue: linked.issue
|
||||
? { number: linked.issue.number, title: linked.issue.title, url: linked.issue.url, contextText: linked.issue.contextText }
|
||||
: null,
|
||||
linkedPr: linked.pr
|
||||
? { number: linked.pr.number, title: linked.pr.title, url: linked.pr.url, instructions: linked.pr.instructionsText, context: linked.pr.contextText }
|
||||
: null,
|
||||
linkedLinearIssue: linked.linear
|
||||
? { identifier: linked.linear.identifier, title: linked.linear.title, url: linked.linear.url, contextText: linked.linear.contextText }
|
||||
: null,
|
||||
}, skillInstruction);
|
||||
const attachmentsToQueue = [...composerAttachments, ...mentionAttachments];
|
||||
|
||||
// Sending while the agent works must still take the reader to the
|
||||
// live edge — a queued message produces no user row yet, so the
|
||||
@@ -965,26 +1065,116 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
// parked mid-history.
|
||||
scrollToLatest?.();
|
||||
|
||||
// Clear input and attachments
|
||||
// Note: confirmedMentionsRef is NOT cleared here because queued messages
|
||||
// are processed later in handleSubmit which reads the ref via extractInlineFileMentions.
|
||||
// The ref is cleared in handleSubmit after all queued messages are sent.
|
||||
// Clear the composer. The mentions it had confirmed were resolved
|
||||
// above, so nothing later needs them.
|
||||
setMessage('');
|
||||
if (attachmentsToQueue.length > 0) {
|
||||
confirmedMentionsRef.current.clear();
|
||||
if (composerAttachments.length > 0) {
|
||||
clearAttachedFiles();
|
||||
}
|
||||
|
||||
setLinkedIssue(null);
|
||||
setLinkedPr(null);
|
||||
setLinkedLinearIssue(null);
|
||||
if (!isMobile) {
|
||||
composerRef.current?.focus();
|
||||
}
|
||||
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, currentProviderId, currentModelId, currentAgentName, currentVariant, scrollToLatest, agents, t]);
|
||||
|
||||
const handleQueuedMessageEdit = React.useCallback((content: string) => {
|
||||
setMessage(content);
|
||||
try {
|
||||
await addToQueue(queueTarget, {
|
||||
content: messageToQueue,
|
||||
text: sanitizedText,
|
||||
agentMention: mention?.name,
|
||||
attachments: attachmentsToQueue.length > 0 ? attachmentsToQueue : undefined,
|
||||
context: context.length > 0 ? context : undefined,
|
||||
sendConfig: currentProviderId && currentModelId ? {
|
||||
providerID: currentProviderId,
|
||||
modelID: currentModelId,
|
||||
agent: currentAgentName ?? undefined,
|
||||
variant: currentVariant ?? undefined,
|
||||
} : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[queue] failed to queue message:', error);
|
||||
toast.error(t('chat.queuedMessage.toast.queueFailed'));
|
||||
// The composer was cleared on queueing; give everything back. The
|
||||
// text is appended if the user has already typed something new.
|
||||
const currentInput = composerRef.current?.getValue() ?? messageRef.current;
|
||||
if (!currentInput) {
|
||||
setMessage(messageToQueue);
|
||||
} else {
|
||||
useInputStore.getState().setPendingInputText(messageToQueue, 'append');
|
||||
}
|
||||
if (composerAttachments.length > 0) {
|
||||
useInputStore.getState().setAttachedFiles([...useInputStore.getState().attachedFiles, ...composerAttachments]);
|
||||
}
|
||||
if (draftTarget && drafts.length > 0) {
|
||||
useInlineCommentDraftStore.getState().restoreDrafts(draftTarget, drafts);
|
||||
}
|
||||
if (syntheticParts.length > 0) {
|
||||
useInputStore.getState().setPendingSyntheticParts(syntheticParts);
|
||||
}
|
||||
setLinkedIssue(linked.issue);
|
||||
setLinkedPr(linked.pr);
|
||||
setLinkedLinearIssue(linked.linear);
|
||||
return;
|
||||
}
|
||||
recordLinkedReferences(queueSessionId, queueTarget.directory, linked);
|
||||
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, attachedFiles, sanitizeAttachmentsForSend, prepareDocumentMentions, extractInlineFileMentions, agents, currentDirectory, consumePendingSyntheticParts, inlineDraftTarget, consumeDrafts, linkedIssue, linkedPr, linkedLinearIssue, scrollToLatest, clearAttachedFiles, isMobile, addToQueue, currentProviderId, currentModelId, currentAgentName, currentVariant, t]);
|
||||
|
||||
/** Put the context a queued message was captured with back on the composer chips. */
|
||||
const restoreQueuedContext = React.useCallback((context: readonly QueuedContextPart[]) => {
|
||||
const synthetic: SyntheticContextPart[] = [];
|
||||
for (const part of context) {
|
||||
if (part.kind === 'synthetic') {
|
||||
synthetic.push({ text: part.text, synthetic: true });
|
||||
continue;
|
||||
}
|
||||
// An instruction is derived from the text, and derived again on send.
|
||||
if (part.kind !== 'context') continue;
|
||||
const payload = part.metadata[CONTEXT_METADATA_KEY];
|
||||
if (payload.kind === 'github-issue') {
|
||||
setLinkedIssue({ number: payload.number, title: payload.title, url: payload.url, contextText: part.text });
|
||||
setLinkedPr(null);
|
||||
setLinkedLinearIssue(null);
|
||||
} else if (payload.kind === 'github-pr') {
|
||||
// The captured context is final: whatever diff it includes is
|
||||
// already in the text, and the branches were not captured.
|
||||
setLinkedPr({
|
||||
number: payload.number,
|
||||
title: payload.title,
|
||||
url: payload.url,
|
||||
head: '',
|
||||
base: '',
|
||||
includeDiff: false,
|
||||
instructionsText: part.instructions ?? '',
|
||||
contextText: part.text,
|
||||
});
|
||||
setLinkedIssue(null);
|
||||
setLinkedLinearIssue(null);
|
||||
} else if (payload.kind === 'linear-issue') {
|
||||
setLinkedLinearIssue({ identifier: payload.identifier, title: payload.title, url: payload.url, contextText: part.text });
|
||||
setLinkedIssue(null);
|
||||
setLinkedPr(null);
|
||||
} else {
|
||||
const draft = draftFromContextPayload(payload);
|
||||
if (draft && inlineDraftTarget) {
|
||||
useInlineCommentDraftStore.getState().addDraft(inlineDraftTarget, draft);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (synthetic.length > 0) {
|
||||
const pending = useInputStore.getState().pendingSyntheticParts ?? [];
|
||||
useInputStore.getState().setPendingSyntheticParts([...pending, ...synthetic]);
|
||||
}
|
||||
}, [inlineDraftTarget]);
|
||||
|
||||
const handleQueuedMessageEdit = React.useCallback((queued: QueuedMessage) => {
|
||||
setMessage(queued.content);
|
||||
restoreQueuedContext(queued.context ?? []);
|
||||
setTimeout(() => {
|
||||
composerRef.current?.focus();
|
||||
}, 0);
|
||||
}, []);
|
||||
}, [restoreQueuedContext]);
|
||||
|
||||
const handleQueuedMessageSend = React.useCallback((messageId: string) => {
|
||||
// Force-sending from the queue during a busy session counts as steer
|
||||
@@ -1079,7 +1269,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
// rejected turn winds down and the session returns to idle. This avoids
|
||||
// aborting the turn (which would surface an "aborted" notice).
|
||||
if (currentSessionId && !queuedOnly && autoReviewRunning && !isBtwActive) {
|
||||
handleQueueMessage();
|
||||
void handleQueueMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1101,7 +1291,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
sessionActions.dismissOpenQuestionsForSession(currentSessionId),
|
||||
]);
|
||||
if (deniedPermissions || dismissedQuestions) {
|
||||
handleQueueMessage();
|
||||
void handleQueueMessage();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1125,43 +1315,23 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
}
|
||||
if (delivery && sendMessageOptions) sendMessageOptions.delivery = delivery;
|
||||
|
||||
const preparedDocumentMentions = new Map<string, AttachedFile[]>();
|
||||
// Queued messages resolved their mentions when they were queued; only
|
||||
// the composer's own text can still name a document.
|
||||
const reservedFilenames = new Set([
|
||||
...attachedFiles.map((attachment) => attachment.filename),
|
||||
...queuedProjection.flatMap((queued) => queued.attachments?.map((attachment) => attachment.filename) ?? []),
|
||||
]);
|
||||
const mentionTexts = [
|
||||
...queuedProjection.map((queued) => queued.content),
|
||||
...(!queuedOnly && inputSnapshot.hasContent ? [inputSnapshot.message] : []),
|
||||
];
|
||||
for (const rawText of mentionTexts) {
|
||||
for (const token of scanMentions(rawText)) {
|
||||
const mention = resolveInlineFileMention(token.name);
|
||||
if (
|
||||
!mention
|
||||
|| !isDocumentAttachmentFilename(mention.filename)
|
||||
|| preparedDocumentMentions.has(mention.serverPath)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const response = await runtimeFetch('/api/fs/raw', { query: { path: mention.serverPath } });
|
||||
if (!response.ok) throw new Error(`Failed to read ${mention.filename}`);
|
||||
const sourceBlob = await response.blob();
|
||||
if (getRuntimeKey() !== submitRuntimeKey) return;
|
||||
const source = new File([sourceBlob], mention.filename);
|
||||
const prepared = await prepareLocalAttachments(source, reservedFilenames);
|
||||
if (!prepared || prepared.length === 0) throw new Error(`Failed to prepare ${mention.filename}`);
|
||||
if (getRuntimeKey() !== submitRuntimeKey) return;
|
||||
preparedDocumentMentions.set(mention.serverPath, prepared);
|
||||
for (const attachment of prepared) reservedFilenames.add(attachment.filename);
|
||||
} catch {
|
||||
if (getRuntimeKey() !== submitRuntimeKey) return;
|
||||
toast.error(t('chat.chatInput.toast.attachNamedFailed', { name: mention.filename }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
const documentMentions = await prepareDocumentMentions(
|
||||
!queuedOnly && inputSnapshot.hasContent ? [inputSnapshot.message] : [],
|
||||
reservedFilenames,
|
||||
submitRuntimeKey,
|
||||
);
|
||||
if (documentMentions.status === 'runtime-changed') return;
|
||||
if (documentMentions.status === 'failed') {
|
||||
toast.error(t('chat.chatInput.toast.attachNamedFailed', { name: documentMentions.filename }));
|
||||
return;
|
||||
}
|
||||
const preparedDocumentMentions = documentMentions.prepared;
|
||||
|
||||
// The composer delivers these itself, so they leave the queue now — the
|
||||
// queue's own delivery (server-side, or the auto-send hook in VS Code)
|
||||
@@ -1180,9 +1350,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
}
|
||||
|
||||
// Inline review comments and synthetic context are consumed before
|
||||
// assembly so a failed send can restore exactly what it took. Context
|
||||
// drafts ride with whichever send goes out next, including queued
|
||||
// auto-sends: queueing leaves them in the store on purpose.
|
||||
// assembly so a failed send can restore exactly what it took. What is
|
||||
// here belongs to this send: queueing took its own context with it.
|
||||
const syntheticParts = consumePendingSyntheticParts();
|
||||
const consumedDraftTarget = inlineDraftTarget;
|
||||
const drafts: InlineCommentDraft[] = consumedDraftTarget
|
||||
@@ -1405,16 +1574,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
};
|
||||
|
||||
void sendPromise.then(() => {
|
||||
// Record what this session was pointed at, so the work-status panel
|
||||
// can show it as a context source long after the message scrolled
|
||||
// away. A snapshot only — never re-fetched, never authoritative.
|
||||
// Failures are swallowed: the message went out, and a missing
|
||||
// bookkeeping entry must not surface as a send error.
|
||||
const attachedThread = linkedIssue
|
||||
? { attachment: linkedIssue, kind: 'issue' as const }
|
||||
: linkedPr
|
||||
? { attachment: linkedPr, kind: 'pull' as const }
|
||||
: null;
|
||||
// On a draft there is no session yet in this closure: the send path
|
||||
// creates one and makes it current before resolving, so the id is
|
||||
// read from the store. The fallback is used only when the closure
|
||||
@@ -1427,47 +1586,14 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
: sessionState.currentSessionDirectory
|
||||
?? (linkTargetSessionId ? sessionState.getDirectoryForSession(linkTargetSessionId) : null)
|
||||
?? currentDirectory;
|
||||
|
||||
if (attachedThread && linkTargetSessionId) {
|
||||
void sessionActions.setLinkedIssue(
|
||||
linkTargetSessionId,
|
||||
linkTargetDirectory,
|
||||
buildLinkedIssue({
|
||||
url: attachedThread.attachment.url,
|
||||
number: attachedThread.attachment.number,
|
||||
title: attachedThread.attachment.title,
|
||||
kind: attachedThread.kind,
|
||||
author: attachedThread.attachment.author,
|
||||
linkedAt: Date.now(),
|
||||
}),
|
||||
true,
|
||||
).catch(() => undefined);
|
||||
}
|
||||
if (linkedLinearIssue && linkTargetSessionId) {
|
||||
void sessionActions.setLinkedIssue(
|
||||
linkTargetSessionId,
|
||||
linkTargetDirectory,
|
||||
buildLinkedLinearIssue({
|
||||
identifier: linkedLinearIssue.identifier,
|
||||
title: linkedLinearIssue.title,
|
||||
url: linkedLinearIssue.url,
|
||||
author: linkedLinearIssue.author,
|
||||
linkedAt: Date.now(),
|
||||
}),
|
||||
true,
|
||||
).catch(() => undefined);
|
||||
if (linkTargetSessionId) {
|
||||
recordLinkedReferences(linkTargetSessionId, linkTargetDirectory, { issue: linkedIssue, pr: linkedPr, linear: linkedLinearIssue });
|
||||
}
|
||||
|
||||
// Clear linked issue after successful message send
|
||||
if (linkedIssue) {
|
||||
setLinkedIssue(null);
|
||||
}
|
||||
if (linkedPr) {
|
||||
setLinkedPr(null);
|
||||
}
|
||||
if (linkedLinearIssue) {
|
||||
setLinkedLinearIssue(null);
|
||||
}
|
||||
// Linked references were sent; clear them from the composer.
|
||||
setLinkedIssue(null);
|
||||
setLinkedPr(null);
|
||||
setLinkedLinearIssue(null);
|
||||
}).catch((error: unknown) => {
|
||||
const rawMessage =
|
||||
error instanceof Error
|
||||
@@ -1555,7 +1681,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
const inputSnapshot = getCurrentInputSnapshot();
|
||||
const canQueue = !isBtwActive && inputMode === 'normal' && inputSnapshot.hasContent && currentSessionId && (currentSessionPhase !== 'idle' || autoReviewRunning);
|
||||
if (followUpBehavior === 'queue' && canQueue) {
|
||||
handleQueueMessage();
|
||||
void handleQueueMessage();
|
||||
} else if (followUpBehavior === 'steer' && canQueue) {
|
||||
void handleSubmitRef.current({ delivery: 'steer' });
|
||||
} else {
|
||||
@@ -1762,7 +1888,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
if (isCtrlEnter || !canQueue) {
|
||||
handleSubmit();
|
||||
} else {
|
||||
handleQueueMessage();
|
||||
void handleQueueMessage();
|
||||
}
|
||||
} else {
|
||||
// steer: Enter steers into the running turn, Ctrl+Enter sends now.
|
||||
@@ -2839,7 +2965,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
title={linkedPr.title}
|
||||
url={linkedPr.url}
|
||||
author={linkedPr.author}
|
||||
branches={{ head: linkedPr.head, base: linkedPr.base }}
|
||||
branches={linkedPr.head && linkedPr.base ? { head: linkedPr.head, base: linkedPr.base } : undefined}
|
||||
openInBrowserLabel={t('chat.chatInput.linked.pr.openInBrowserAria')}
|
||||
removeLabel={t('chat.chatInput.linked.pr.removeAria')}
|
||||
onReopenPicker={() => setPrPickerOpen(true)}
|
||||
|
||||
@@ -103,7 +103,8 @@ const QueuedMessageChip = memo(({ message, target, onEdit, onSend }: QueuedMessa
|
||||
QueuedMessageChip.displayName = 'QueuedMessageChip';
|
||||
|
||||
interface QueuedMessageChipsProps {
|
||||
onEditMessage: (content: string, attachments?: QueuedMessage['attachments']) => void;
|
||||
/** The message was taken from the queue in full; the composer restores it. */
|
||||
onEditMessage: (message: QueuedMessage) => void;
|
||||
onSendMessage: (messageId: string) => void;
|
||||
}
|
||||
|
||||
@@ -159,7 +160,7 @@ export const QueuedMessageChips = memo(({ onEditMessage, onSendMessage }: Queued
|
||||
const currentAttachments = useInputStore.getState().attachedFiles;
|
||||
useInputStore.getState().setAttachedFiles([...currentAttachments, ...popped.attachments]);
|
||||
}
|
||||
onEditMessage(popped.content, popped.attachments);
|
||||
onEditMessage(popped);
|
||||
}).catch((error) => {
|
||||
console.warn('[queue] failed to take queued message for editing:', error);
|
||||
toast.error(t('chat.queuedMessage.toast.takeFailed'));
|
||||
|
||||
@@ -159,8 +159,14 @@ and the send path reading the same grammar.
|
||||
linked issue/PR) becomes its own synthetic text part carrying structured
|
||||
metadata** built by `lib/messages/contextParts.ts`; the timeline reads that
|
||||
metadata back to render context blocks. PR instructions precede the PR diff.
|
||||
Queueing a message leaves context drafts in their store on purpose — the send
|
||||
that later delivers the queue consumes them.
|
||||
The same module's `buildComposerContext` captures that context when a message
|
||||
is **queued** instead of sent: the chips leave the composer with the message
|
||||
(as `QueuedContextPart`s on the queue item), the server or the VS Code
|
||||
auto-send delivers them through `queuedContextToParts`, and editing the
|
||||
queued message puts them back. A queued message is placed as captured — its
|
||||
mention, file mentions, and skill instruction were resolved when it was
|
||||
queued, never at delivery — and its context follows it before the next
|
||||
queued message.
|
||||
- `state/useComposerDraft.ts` — a draft belongs to a (runtime, directory,
|
||||
session) identity. Writes are debounced while typing but forced at every edge
|
||||
where the page may stop running, because a pending timer is not a saved
|
||||
|
||||
+87
-10
@@ -3,8 +3,12 @@ import { describe, expect, test } from 'bun:test';
|
||||
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
||||
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
||||
import { CONTEXT_METADATA_KEY, contextPayloadFromDraft } from '@/lib/messages/contextParts';
|
||||
import type { QueuedContextPart } from '@/stores/messageQueueStore';
|
||||
import {
|
||||
buildComposerContext,
|
||||
buildOutgoingMessage,
|
||||
queuedContextToParts,
|
||||
type ComposerContextInput,
|
||||
type OutgoingMessageDeps,
|
||||
type OutgoingMessageInput,
|
||||
} from '../buildOutgoingMessage';
|
||||
@@ -78,7 +82,7 @@ describe('the composer text alone', () => {
|
||||
describe('queued messages', () => {
|
||||
test('the oldest becomes primary and the rest follow in order', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [{ content: 'first' }, { content: 'second' }, { content: 'third' }],
|
||||
queued: [{ text: 'first' }, { text: 'second' }, { text: 'third' }],
|
||||
}), deps());
|
||||
expect(result.primaryText).toBe('first');
|
||||
expect(result.additionalParts.map((p) => p.text)).toEqual(['second', 'third']);
|
||||
@@ -86,18 +90,43 @@ describe('queued messages', () => {
|
||||
|
||||
test('the composer text lands after everything queued', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [{ content: 'queued' }],
|
||||
queued: [{ text: 'queued' }],
|
||||
composerText: 'typed now',
|
||||
}), deps());
|
||||
expect(result.primaryText).toBe('queued');
|
||||
expect(result.additionalParts.map((p) => p.text)).toEqual(['typed now']);
|
||||
});
|
||||
|
||||
test('the context a message was queued with follows it, before the next message', () => {
|
||||
const metadata = { [CONTEXT_METADATA_KEY]: { kind: 'github-issue' as const, number: 3, title: 'Bug', url: 'https://x/issues/3' } };
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [
|
||||
{ text: 'first', context: [{ kind: 'context', text: 'issue body', metadata }, { kind: 'instruction', text: 'use: deploy' }] },
|
||||
{ text: 'second' },
|
||||
],
|
||||
composerText: 'typed now',
|
||||
}), deps());
|
||||
expect(result.primaryText).toBe('first');
|
||||
expect(result.additionalParts.map((p) => p.text)).toEqual(['issue body', 'use: deploy', 'second', 'typed now']);
|
||||
expect(result.additionalParts[0]).toEqual({ text: 'issue body', synthetic: true, metadata });
|
||||
expect(result.additionalParts[1]).toEqual({ text: 'use: deploy', synthetic: true });
|
||||
});
|
||||
|
||||
test('a queued message is placed as captured, never re-resolved', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [{ text: '@agent:plan see @file:doc and /deploy' }],
|
||||
}), deps());
|
||||
expect(result.primaryText).toBe('@agent:plan see @file:doc and /deploy');
|
||||
expect(result.primaryAttachments).toEqual([]);
|
||||
expect(result.agentMentionName).toBe(undefined);
|
||||
expect(result.additionalParts).toEqual([]);
|
||||
});
|
||||
|
||||
test('each queued message keeps its own attachments', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [
|
||||
{ content: 'a', attachments: [attachment('one')] },
|
||||
{ content: 'b', attachments: [attachment('two')] },
|
||||
{ text: 'a', attachments: [attachment('one')] },
|
||||
{ text: 'b', attachments: [attachment('two')] },
|
||||
],
|
||||
}), deps());
|
||||
expect(result.primaryAttachments.map((a) => a.id)).toEqual(['one']);
|
||||
@@ -113,14 +142,14 @@ describe('agent mentions', () => {
|
||||
|
||||
test('the first mention wins across queued messages', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [{ content: '@agent:plan a' }, { content: '@agent:build b' }],
|
||||
queued: [{ text: 'a', agentMention: 'plan' }, { text: 'b', agentMention: 'build' }],
|
||||
}), deps());
|
||||
expect(result.agentMentionName).toBe('plan');
|
||||
});
|
||||
|
||||
test('a queued mention outranks one typed later', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [{ content: '@agent:plan a' }],
|
||||
queued: [{ text: 'a', agentMention: 'plan' }],
|
||||
composerText: '@agent:build b',
|
||||
}), deps());
|
||||
expect(result.agentMentionName).toBe('plan');
|
||||
@@ -230,10 +259,9 @@ describe('synthetic context', () => {
|
||||
expect(result.additionalParts.at(-1)).toEqual({ text: 'use: deploy', synthetic: true });
|
||||
});
|
||||
|
||||
test('skills are collected across every authored body, without duplicates', () => {
|
||||
test('skills named in the composer are collected without duplicates', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [{ content: '/deploy a' }],
|
||||
composerText: '/deploy and /audit',
|
||||
composerText: '/deploy and /audit and /deploy',
|
||||
}), deps());
|
||||
expect(result.additionalParts.at(-1)?.text).toBe('use: deploy,audit');
|
||||
});
|
||||
@@ -262,7 +290,7 @@ describe('synthetic context', () => {
|
||||
describe('full assembly order', () => {
|
||||
test('queued, then typed, then synthetic, then references, then skills', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [{ content: 'q1' }, { content: 'q2' }],
|
||||
queued: [{ text: 'q1' }, { text: 'q2' }],
|
||||
composerText: 'typed /deploy',
|
||||
syntheticTexts: ['synthetic'],
|
||||
linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue' },
|
||||
@@ -283,3 +311,52 @@ describe('full assembly order', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('capturing composer context for the queue', () => {
|
||||
const contextInput = (overrides: Partial<ComposerContextInput> = {}): ComposerContextInput => ({
|
||||
inlineComments: [],
|
||||
syntheticTexts: [],
|
||||
linkedIssue: null,
|
||||
linkedPr: null,
|
||||
linkedLinearIssue: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('captures everything attached, in send order, with the skill instruction last', () => {
|
||||
const context = buildComposerContext(contextInput({
|
||||
inlineComments: [commentDraft()],
|
||||
syntheticTexts: ['conflict note'],
|
||||
linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue' },
|
||||
linkedPr: { number: 7, title: 'PR', url: 'https://x/pr/7', instructions: 'pr-how', context: 'pr-diff' },
|
||||
linkedLinearIssue: { identifier: 'ENG-12', title: 'Login', url: 'https://linear.app/x/issue/ENG-12', contextText: 'linear' },
|
||||
}), 'use: deploy');
|
||||
|
||||
expect(context.map((part) => part.kind)).toEqual(['context', 'synthetic', 'context', 'context', 'context', 'instruction']);
|
||||
expect(context[0]?.kind).toBe('context');
|
||||
expect(context[0]?.text).toContain('Comment on `src/app.ts` lines 3-5 (modified):');
|
||||
expect(context[0]?.kind === 'context' ? context[0].metadata : null)
|
||||
.toEqual({ [CONTEXT_METADATA_KEY]: contextPayloadFromDraft(commentDraft()) });
|
||||
expect(context[3]).toEqual({
|
||||
kind: 'context',
|
||||
text: 'pr-diff',
|
||||
instructions: 'pr-how',
|
||||
metadata: { [CONTEXT_METADATA_KEY]: { kind: 'github-pr', number: 7, title: 'PR', url: 'https://x/pr/7' } },
|
||||
});
|
||||
expect(context.at(-1)).toEqual({ kind: 'instruction', text: 'use: deploy' });
|
||||
});
|
||||
|
||||
test('nothing attached captures nothing', () => {
|
||||
expect(buildComposerContext(contextInput(), null)).toEqual([]);
|
||||
});
|
||||
|
||||
test('delivering captured context reproduces the composer parts exactly', () => {
|
||||
const input = contextInput({
|
||||
inlineComments: [commentDraft()],
|
||||
syntheticTexts: ['conflict note'],
|
||||
linkedPr: { number: 7, title: 'PR', url: 'https://x/pr/7', instructions: 'pr-how', context: 'pr-diff' },
|
||||
});
|
||||
const captured: QueuedContextPart[] = buildComposerContext(input, 'use: deploy');
|
||||
const direct = buildOutgoingMessage({ ...input, queued: [], composerText: 'use /deploy', composerAttachments: [] }, deps());
|
||||
expect(queuedContextToParts(captured)).toEqual(direct.additionalParts);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
||||
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
||||
import type { QueuedContextPart } from '@/stores/messageQueueStore';
|
||||
import { contextPayloadFromDraft, createContextPart, type ContextPartMetadata } from '@/lib/messages/contextParts';
|
||||
|
||||
export interface OutgoingPart {
|
||||
@@ -36,17 +37,20 @@ export interface OutgoingMessage {
|
||||
isEmpty: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A queued message is already resolved: its agent mention was stripped, its
|
||||
* file mentions became attachments, and the context the composer had attached
|
||||
* travels with it. Assembly only places it.
|
||||
*/
|
||||
export interface QueuedInput {
|
||||
content: string;
|
||||
text: string;
|
||||
agentMention?: string;
|
||||
attachments?: AttachedFile[];
|
||||
context?: readonly QueuedContextPart[];
|
||||
}
|
||||
|
||||
export interface OutgoingMessageInput {
|
||||
/** Messages queued while a turn was running, oldest first. */
|
||||
queued: readonly QueuedInput[];
|
||||
/** The composer's own text, or null when this send skips it. */
|
||||
composerText: string | null;
|
||||
composerAttachments: readonly AttachedFile[];
|
||||
/** What the composer has attached besides text and files. */
|
||||
export interface ComposerContextInput {
|
||||
/** Context drafts (code comments, terminal selections, annotations, PR context). */
|
||||
inlineComments: readonly InlineCommentDraft[];
|
||||
/** Synthetic context produced elsewhere (conflict resolution, and such). */
|
||||
@@ -56,6 +60,14 @@ export interface OutgoingMessageInput {
|
||||
linkedLinearIssue: { identifier: string; title: string; url: string; contextText: string } | null;
|
||||
}
|
||||
|
||||
export interface OutgoingMessageInput extends ComposerContextInput {
|
||||
/** Messages queued while a turn was running, oldest first. */
|
||||
queued: readonly QueuedInput[];
|
||||
/** The composer's own text, or null when this send skips it. */
|
||||
composerText: string | null;
|
||||
composerAttachments: readonly AttachedFile[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The parts of assembly that depend on stores or async config, injected so the
|
||||
* assembly itself stays pure.
|
||||
@@ -104,20 +116,19 @@ export function buildOutgoingMessage(
|
||||
};
|
||||
|
||||
// Queued messages come first, in the order they were queued: the oldest
|
||||
// becomes the primary message so the turn reads chronologically.
|
||||
// becomes the primary message so the turn reads chronologically. Each one
|
||||
// is followed by the context it was queued with.
|
||||
input.queued.forEach((queued, index) => {
|
||||
const resolved = resolve(queued.content);
|
||||
const attachments = [
|
||||
...deps.sanitizeAttachments(queued.attachments),
|
||||
...resolved.attachments,
|
||||
];
|
||||
noteAgent(queued.agentMention);
|
||||
const attachments = deps.sanitizeAttachments(queued.attachments);
|
||||
|
||||
if (index === 0) {
|
||||
primaryText = resolved.text;
|
||||
primaryText = queued.text;
|
||||
primaryAttachments = attachments;
|
||||
return;
|
||||
} else {
|
||||
additionalParts.push({ text: queued.text, attachments });
|
||||
}
|
||||
additionalParts.push({ text: resolved.text, attachments });
|
||||
additionalParts.push(...queuedContextToParts(queued.context ?? []));
|
||||
});
|
||||
|
||||
// The composer's own text follows, becoming primary only when nothing was
|
||||
@@ -137,40 +148,10 @@ export function buildOutgoingMessage(
|
||||
}
|
||||
}
|
||||
|
||||
// Everything below is context for the model, never plain user text. Each
|
||||
// attached context item becomes its own synthetic part carrying structured
|
||||
// metadata, so the timeline can render it as a context block after the
|
||||
// server echoes the message back.
|
||||
for (const draft of input.inlineComments) {
|
||||
additionalParts.push(createContextPart(contextPayloadFromDraft(draft)));
|
||||
}
|
||||
|
||||
for (const text of input.syntheticTexts) {
|
||||
additionalParts.push({ text, synthetic: true });
|
||||
}
|
||||
|
||||
if (input.linkedIssue) {
|
||||
const { number, title, url, contextText } = input.linkedIssue;
|
||||
additionalParts.push(createContextPart({ kind: 'github-issue', number, title, url }, contextText));
|
||||
}
|
||||
|
||||
if (input.linkedPr) {
|
||||
// Instructions before context: the model is told how to read the diff
|
||||
// before it is given the diff.
|
||||
const { number, title, url, instructions, context } = input.linkedPr;
|
||||
additionalParts.push({ text: instructions, synthetic: true });
|
||||
additionalParts.push(createContextPart({ kind: 'github-pr', number, title, url }, context));
|
||||
}
|
||||
|
||||
if (input.linkedLinearIssue) {
|
||||
const { identifier, title, url, contextText } = input.linkedLinearIssue;
|
||||
additionalParts.push(createContextPart({ kind: 'linear-issue', identifier, title, url }, contextText));
|
||||
}
|
||||
|
||||
const skillInstruction = deps.buildSkillInstruction(skillNames);
|
||||
if (skillInstruction) {
|
||||
additionalParts.push({ text: skillInstruction, synthetic: true });
|
||||
}
|
||||
// Everything the composer had attached follows its text.
|
||||
additionalParts.push(...queuedContextToParts(
|
||||
buildComposerContext(input, deps.buildSkillInstruction(skillNames)),
|
||||
));
|
||||
|
||||
return {
|
||||
primaryText,
|
||||
@@ -180,3 +161,68 @@ export function buildOutgoingMessage(
|
||||
isEmpty: !primaryText && primaryAttachments.length === 0 && additionalParts.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the composer has attached besides text and files, in send
|
||||
* order. Each attached context item becomes its own synthetic part carrying
|
||||
* structured metadata, so the timeline can render it as a context block after
|
||||
* the server echoes the message back. Used both when sending and when queueing:
|
||||
* a queued message takes this context with it, so whoever delivers it later
|
||||
* sends exactly what the composer would have.
|
||||
*/
|
||||
export function buildComposerContext(
|
||||
input: ComposerContextInput,
|
||||
skillInstruction: string | null,
|
||||
): QueuedContextPart[] {
|
||||
const context: QueuedContextPart[] = [];
|
||||
const attach = (part: { text: string; metadata: ContextPartMetadata }, instructions?: string) => {
|
||||
const entry: QueuedContextPart = { kind: 'context', text: part.text, metadata: part.metadata };
|
||||
if (instructions) entry.instructions = instructions;
|
||||
context.push(entry);
|
||||
};
|
||||
|
||||
for (const draft of input.inlineComments) {
|
||||
attach(createContextPart(contextPayloadFromDraft(draft)));
|
||||
}
|
||||
|
||||
for (const text of input.syntheticTexts) {
|
||||
context.push({ kind: 'synthetic', text });
|
||||
}
|
||||
|
||||
if (input.linkedIssue) {
|
||||
const { number, title, url, contextText } = input.linkedIssue;
|
||||
attach(createContextPart({ kind: 'github-issue', number, title, url }, contextText));
|
||||
}
|
||||
|
||||
if (input.linkedPr) {
|
||||
// Instructions before context: the model is told how to read the diff
|
||||
// before it is given the diff.
|
||||
const { number, title, url, instructions, context: prContext } = input.linkedPr;
|
||||
attach(createContextPart({ kind: 'github-pr', number, title, url }, prContext), instructions);
|
||||
}
|
||||
|
||||
if (input.linkedLinearIssue) {
|
||||
const { identifier, title, url, contextText } = input.linkedLinearIssue;
|
||||
attach(createContextPart({ kind: 'linear-issue', identifier, title, url }, contextText));
|
||||
}
|
||||
|
||||
if (skillInstruction) {
|
||||
context.push({ kind: 'instruction', text: skillInstruction });
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/** The synthetic parts a captured context is delivered as, in order. */
|
||||
export function queuedContextToParts(context: readonly QueuedContextPart[]): OutgoingPart[] {
|
||||
const parts: OutgoingPart[] = [];
|
||||
for (const part of context) {
|
||||
if (part.kind !== 'context') {
|
||||
parts.push({ text: part.text, synthetic: true });
|
||||
continue;
|
||||
}
|
||||
if (part.instructions) parts.push({ text: part.instructions, synthetic: true });
|
||||
parts.push({ text: part.text, synthetic: true, metadata: part.metadata });
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
@@ -186,11 +186,13 @@ describe('buildQueuedAutoSendPayload', () => {
|
||||
{
|
||||
id: 'queued-1',
|
||||
content: 'first queued message',
|
||||
text: 'first queued message',
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: 'queued-2',
|
||||
content: 'second queued message',
|
||||
text: 'second queued message',
|
||||
createdAt: 2,
|
||||
},
|
||||
];
|
||||
@@ -203,20 +205,18 @@ describe('buildQueuedAutoSendPayload', () => {
|
||||
expect(payload?.primaryAttachments).toEqual([]);
|
||||
});
|
||||
|
||||
test('uses the configured visible agents when parsing queued mentions', () => {
|
||||
visibleAgents = [
|
||||
{
|
||||
name: 'Builder',
|
||||
mode: 'subagent',
|
||||
permission: [],
|
||||
options: {},
|
||||
} as Agent,
|
||||
];
|
||||
|
||||
test('delivers the captured mention and context instead of re-parsing the content', () => {
|
||||
const metadata = { openchamberContext: { kind: 'github-issue' as const, number: 3, title: 'Bug', url: 'https://x/issues/3' } };
|
||||
const queue: QueuedMessage[] = [
|
||||
{
|
||||
id: 'queued-mention',
|
||||
content: '@Builder please take this',
|
||||
text: 'please take this',
|
||||
agentMention: 'Builder',
|
||||
context: [
|
||||
{ kind: 'context', text: 'issue body', metadata },
|
||||
{ kind: 'instruction', text: 'use the skill' },
|
||||
],
|
||||
createdAt: 1,
|
||||
},
|
||||
];
|
||||
@@ -225,7 +225,11 @@ describe('buildQueuedAutoSendPayload', () => {
|
||||
|
||||
expect(payload).not.toBeNull();
|
||||
expect(payload?.agentMentionName).toBe('Builder');
|
||||
expect(payload?.primaryText).toBe('@Builder please take this');
|
||||
expect(payload?.primaryText).toBe('please take this');
|
||||
expect(payload?.additionalParts).toEqual([
|
||||
{ text: 'issue body', synthetic: true, metadata },
|
||||
{ text: 'use the skill', synthetic: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test('preserves attachment-only queued messages as sendable payloads', () => {
|
||||
@@ -233,6 +237,7 @@ describe('buildQueuedAutoSendPayload', () => {
|
||||
{
|
||||
id: 'queued-attachments',
|
||||
content: '',
|
||||
text: '',
|
||||
createdAt: 1,
|
||||
attachments: [
|
||||
{
|
||||
@@ -249,6 +254,7 @@ describe('buildQueuedAutoSendPayload', () => {
|
||||
{
|
||||
id: 'queued-2',
|
||||
content: 'later queued message',
|
||||
text: 'later queued message',
|
||||
createdAt: 2,
|
||||
},
|
||||
];
|
||||
@@ -267,6 +273,7 @@ describe('buildQueuedAutoSendPayload', () => {
|
||||
{
|
||||
id: 'queued-1',
|
||||
content: 'queued message',
|
||||
text: 'queued message',
|
||||
createdAt: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSelectionStore } from '@/sync/selection-store';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useContextStore } from '@/stores/contextStore';
|
||||
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
|
||||
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
||||
import { queuedContextToParts } from '@/components/chat/composer/submit/buildOutgoingMessage';
|
||||
import { getDirectoryState } from '@/sync/sync-refs';
|
||||
import { useDirectorySync } from '@/sync/sync-context';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
@@ -82,14 +82,14 @@ export const buildQueuedAutoSendPayload = (queue: QueuedMessage[]) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const agents = useConfigStore.getState().getVisibleAgents();
|
||||
const { sanitizedText, mention } = parseAgentMentions(queued.content, agents);
|
||||
|
||||
// A queued message is delivered as captured: mention already stripped,
|
||||
// file mentions resolved, and the context it was queued with following it.
|
||||
return {
|
||||
queuedMessageId: queued.id,
|
||||
primaryText: sanitizedText,
|
||||
primaryText: queued.text,
|
||||
primaryAttachments: queued.attachments ?? [],
|
||||
agentMentionName: mention?.name,
|
||||
agentMentionName: queued.agentMention,
|
||||
additionalParts: queuedContextToParts(queued.context ?? []),
|
||||
sendConfig: queued.sendConfig,
|
||||
};
|
||||
};
|
||||
@@ -114,7 +114,7 @@ export const sendQueuedAutoSendPayload = (
|
||||
resolved.agent,
|
||||
payload.primaryAttachments,
|
||||
payload.agentMentionName,
|
||||
undefined,
|
||||
payload.additionalParts.length > 0 ? payload.additionalParts : undefined,
|
||||
resolved.variant,
|
||||
'normal',
|
||||
{ target },
|
||||
|
||||
@@ -315,6 +315,12 @@ const contextPayloadSchema = z.discriminatedUnion('kind', [
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Part metadata carrying a context payload, for parsing at a trust boundary
|
||||
* (a queued message coming back from the server, for instance).
|
||||
*/
|
||||
export const contextPartMetadataSchema = z.object({ [CONTEXT_METADATA_KEY]: contextPayloadSchema });
|
||||
|
||||
/** The subset of a message part that context read-back inspects. */
|
||||
export type ContextCarrierPart = { type: string } & Pick<TextPart, 'metadata'>;
|
||||
|
||||
|
||||
@@ -64,7 +64,9 @@ These stores coordinate persistent project/session metadata across multiple view
|
||||
|
||||
`useProjectContextStore.ts` caches server-owned project notes, todos, and plan links, keyed by the path-derived project id. It replaced a pair of `window` CustomEvents that made every mounted notes panel re-read the whole project config. Writes are optimistic and roll back on failure; they are serialized per project, because the server's own store does a read-modify-write and two concurrent saves would otherwise race it. A load that resolves while a write is in flight keeps the local value for that field group only, so a slow snapshot cannot undo newer typing while still delivering the plan list it fetched. A failed load sets `error` and preserves the cached snapshot — an unreachable server must never render as "this project has no notes". Note and plan creation are deliberately not optimistic, since ids and timestamps are assigned by the server. Notes, todos, and plans are written through separate routes and tracked by separate in-flight flags, so a todo toggle cannot clobber a note edit in the same window. Pinned notes and plans are assembled into a synthetic context part by `lib/projectContextPinning.ts` at send time; that module tracks per-session what it already sent so an unchanged pinned set is not re-sent every turn.
|
||||
|
||||
`messageQueueStore.ts` has two owners, decided by `isServerOwnedMessageQueue()`. On web, desktop, and mobile the OpenChamber server owns the queue (`packages/web/server/lib/message-queue/`): it delivers queued messages when the session goes idle whether or not any UI is open, and the store is a projection of it — `hydrate()` loads the server snapshot for the active runtime, `openchamber:message-queue.updated` broadcasts keep it current, and every mutation is optimistic locally then settled on the server's copy of that session (a failed round-trip re-reads the server instead of guessing). A per-key server revision rejects stale snapshots. Projection items carry attachment metadata only; `popToInput()`/`takeForSend()` remove the message on the server and get the full payload back, which is why both are async. Messages a previous build left in this browser are uploaded once on the first hydration of a runtime and then dropped from persistence for that runtime (`partialize` skips server-owned runtime keys). VS Code has no server and keeps the local queue with the foreground auto-send hook (`useQueuedMessageAutoSend`, enabled only there); `useMessageQueueHoldSync` tells the server to hold a session's queue while a UI-driven auto-review run is going.
|
||||
`messageQueueStore.ts` has two owners, decided by `isServerOwnedMessageQueue()`. On web, desktop, and mobile the OpenChamber server owns the queue (`packages/web/server/lib/message-queue/`): it delivers queued messages when the session goes idle whether or not any UI is open, and the store is a projection of it — `hydrate()` loads the server snapshot for the active runtime, `openchamber:message-queue.updated` broadcasts keep it current, and every mutation is optimistic locally then settled on the server's copy of that session (a failed round-trip re-reads the server instead of guessing). A per-key server revision rejects stale snapshots. Projection items carry attachment metadata only and no captured context; `popToInput()`/`takeForSend()` remove the message on the server and get the full payload back, which is why both are async.
|
||||
|
||||
A queued message is captured whole, so whoever delivers it sends exactly what the composer would have: `text` (the content with its agent mention stripped and `@file` mentions already resolved into `attachments`), `agentMention`, and `context` — every chip the composer had attached (inline comments, terminal selections, browser annotations, PR comments/checks, quotes, linked issue/PR/Linear references, pending synthetic parts) plus the skill instruction derived from the text. `QueuedContextPart` distinguishes attached items (restored to the chips when the message is edited) from derived instructions (re-derived on send, never restored) and from synthetic parts other surfaces handed the composer (restored as pending). Context is captured by `buildComposerContext` and delivered by `queuedContextToParts` (`components/chat/composer/submit/buildOutgoingMessage.ts`), the same functions the composer uses for its own send. Nothing is re-resolved at delivery: the server has no agent list, no confirmed mentions, and no draft store. Messages a previous build left in this browser are uploaded once on the first hydration of a runtime and then dropped from persistence for that runtime (`partialize` skips server-owned runtime keys). VS Code has no server and keeps the local queue with the foreground auto-send hook (`useQueuedMessageAutoSend`, enabled only there); `useMessageQueueHoldSync` tells the server to hold a session's queue while a UI-driven auto-review run is going.
|
||||
|
||||
In the local (VS Code) mode the store keeps a queued message until its own send resolves, so between dispatch and resolution the entry is still visible to every reader. Dispatchers must therefore mark the send (`markSending`/`clearSending`) and read `getSendableQueue()` — or filter `sendingIds` themselves — instead of dispatching straight from `queuedMessages`; otherwise a composer submit merges a message the auto-send hook is already delivering and it is sent twice (the window is seconds over a relay). `clearQueue()` retains in-flight entries for the same reason. `sendingIds` is deliberately not persisted: a restart has no in-flight sends, and a stale flag would strand a queued message; in the server-owned mode it mirrors the server's in-flight item. Desktop queues use the configured host id as runtime identity, not the current API URL, because an SSH reconnect allocates a new local forwarding port while the remote host remains the same.
|
||||
|
||||
|
||||
@@ -49,11 +49,14 @@ const serverItem = (id: string, content: string, extra: Partial<ServerItem> = {}
|
||||
id,
|
||||
createdAt: 1,
|
||||
content,
|
||||
text: content,
|
||||
attachments: [],
|
||||
sendConfig: { providerID: "p", modelID: "m" },
|
||||
...extra,
|
||||
})
|
||||
|
||||
const issueMetadata = { openchamberContext: { kind: "github-issue" as const, number: 3, title: "Bug", url: "https://x/issues/3" } }
|
||||
|
||||
const session = (items: ServerItem[], sendingId: string | null = null): ServerSession => ({
|
||||
sessionId: "session-1",
|
||||
directory: "/repo",
|
||||
@@ -90,7 +93,7 @@ describe("server-owned message queue", () => {
|
||||
test("hydrate uploads messages queued by an older build before reading the server", async () => {
|
||||
useMessageQueueStore.setState({
|
||||
queuedMessages: {
|
||||
[key]: [{ id: "local-1", content: "from before", createdAt: 1, sendConfig: { providerID: "p", modelID: "m" } }],
|
||||
[key]: [{ id: "local-1", content: "from before", text: "from before", createdAt: 1, sendConfig: { providerID: "p", modelID: "m" } }],
|
||||
},
|
||||
})
|
||||
respond = (call) => (call.method === "POST"
|
||||
@@ -101,7 +104,7 @@ describe("server-owned message queue", () => {
|
||||
expect(calls[0]).toEqual({
|
||||
method: "POST",
|
||||
path: "/api/message-queue/sessions/session-1/items",
|
||||
body: { directory: "/repo", item: { content: "from before", text: "from before", attachments: [], sendConfig: { providerID: "p", modelID: "m" } } },
|
||||
body: { directory: "/repo", item: { content: "from before", text: "from before", attachments: [], context: [], sendConfig: { providerID: "p", modelID: "m" } } },
|
||||
})
|
||||
expect(calls[1]?.path).toBe("/api/message-queue")
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["q1"])
|
||||
@@ -138,6 +141,7 @@ describe("server-owned message queue", () => {
|
||||
text: "hi",
|
||||
agentMention: "reviewer",
|
||||
attachments: [{ id: "att-1", filename: "note.txt", mimeType: "text/plain", size: 2, source: "local", dataUrl: attachment.dataUrl }],
|
||||
context: [],
|
||||
sendConfig: { providerID: "p", modelID: "m", agent: "build" },
|
||||
},
|
||||
},
|
||||
@@ -145,6 +149,36 @@ describe("server-owned message queue", () => {
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["srv-1"])
|
||||
})
|
||||
|
||||
test("addToQueue hands the captured context to the server, and a take brings it back", async () => {
|
||||
const context = [
|
||||
{ kind: "context" as const, text: "issue body", metadata: issueMetadata },
|
||||
{ kind: "synthetic" as const, text: "conflict payload" },
|
||||
]
|
||||
respond = () => json({ revision: 6, session: session([serverItem("srv-1", "with context")]) })
|
||||
await useMessageQueueStore.getState().addToQueue(target, {
|
||||
content: "with context",
|
||||
context,
|
||||
sendConfig: { providerID: "p", modelID: "m" },
|
||||
})
|
||||
expect(calls[0]?.body.item.context).toEqual(context)
|
||||
// The projection carries no context; the server strips payloads from snapshots.
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]?.[0]?.context).toBe(undefined)
|
||||
|
||||
respond = () => json({ revision: 7, session: session([]), item: serverItem("srv-1", "with context", { context }) })
|
||||
const [taken] = await useMessageQueueStore.getState().takeForSend(target, "srv-1")
|
||||
expect(taken?.context).toEqual(context)
|
||||
expect(taken?.text).toBe("with context")
|
||||
})
|
||||
|
||||
test("a server item with malformed context is rejected at the boundary", async () => {
|
||||
respond = () => new Response(JSON.stringify({
|
||||
revision: 8,
|
||||
session: session([]),
|
||||
item: { ...serverItem("srv-1", "x"), context: [{ kind: "context", text: "x", metadata: { openchamberContext: { kind: "nope" } } }] },
|
||||
}), { status: 200 })
|
||||
await expect(useMessageQueueStore.getState().takeForSend(target, "srv-1")).rejects.toThrow()
|
||||
})
|
||||
|
||||
test("addToQueue rolls the optimistic entry back when the server refuses", async () => {
|
||||
respond = () => new Response("nope", { status: 500 })
|
||||
await expect(useMessageQueueStore.getState().addToQueue(target, {
|
||||
@@ -200,7 +234,7 @@ describe("server-owned message queue", () => {
|
||||
})
|
||||
|
||||
test("removeFromQueue and clearQueue update locally and tell the server", async () => {
|
||||
useMessageQueueStore.setState({ queuedMessages: { [key]: [{ id: "q1", content: "a", createdAt: 1 }, { id: "q2", content: "b", createdAt: 2 }] } })
|
||||
useMessageQueueStore.setState({ queuedMessages: { [key]: [{ id: "q1", content: "a", text: "a", createdAt: 1 }, { id: "q2", content: "b", text: "b", createdAt: 2 }] } })
|
||||
respond = () => json({ revision: 10, session: session([serverItem("q2", "b")]) })
|
||||
useMessageQueueStore.getState().removeFromQueue(target, "q1")
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["q2"])
|
||||
@@ -216,7 +250,7 @@ describe("server-owned message queue", () => {
|
||||
})
|
||||
|
||||
test("reorderQueue sends the complete new order", async () => {
|
||||
useMessageQueueStore.setState({ queuedMessages: { [key]: [{ id: "q1", content: "a", createdAt: 1 }, { id: "q2", content: "b", createdAt: 2 }] } })
|
||||
useMessageQueueStore.setState({ queuedMessages: { [key]: [{ id: "q1", content: "a", text: "a", createdAt: 1 }, { id: "q2", content: "b", text: "b", createdAt: 2 }] } })
|
||||
respond = () => json({ revision: 12, session: session([serverItem("q2", "b"), serverItem("q1", "a")]) })
|
||||
useMessageQueueStore.getState().reorderQueue(target, "q2", "q1")
|
||||
await Promise.resolve()
|
||||
|
||||
@@ -44,6 +44,37 @@ describe("message queue runtime ownership", () => {
|
||||
expect(migrated.quarantinedLegacyMessages?.["session-1"]?.[0]?.content).toBe("legacy")
|
||||
})
|
||||
|
||||
test("keeps what was captured at queue time on the local message", () => {
|
||||
const target = createMessageQueueTarget("session-1", "/repo", "runtime-a")!
|
||||
const context = [{ kind: "synthetic" as const, text: "conflict payload" }]
|
||||
useMessageQueueStore.getState().addToQueue(target, {
|
||||
content: "@Builder do it",
|
||||
text: "do it",
|
||||
agentMention: "Builder",
|
||||
context,
|
||||
})
|
||||
useMessageQueueStore.getState().addToQueue(target, { content: "plain" })
|
||||
|
||||
const [captured, plain] = useMessageQueueStore.getState().getQueueForTarget(target)
|
||||
expect(captured?.content).toBe("@Builder do it")
|
||||
expect(captured?.text).toBe("do it")
|
||||
expect(captured?.agentMention).toBe("Builder")
|
||||
expect(captured?.context).toEqual(context)
|
||||
expect(plain?.text).toBe("plain")
|
||||
expect(plain?.agentMention).toBe(undefined)
|
||||
expect(plain?.context).toBe(undefined)
|
||||
})
|
||||
|
||||
test("messages persisted before delivery text existed deliver their content", () => {
|
||||
const migrated = migrateMessageQueueState({
|
||||
queuedMessages: {
|
||||
"runtime-a\n/repo\nsession-1": [{ id: "queued-1", content: "@Builder old", createdAt: 1 }],
|
||||
},
|
||||
}, 2)
|
||||
|
||||
expect(migrated.queuedMessages?.["runtime-a\n/repo\nsession-1"]?.[0]?.text).toBe("@Builder old")
|
||||
})
|
||||
|
||||
test("bounds each queue to the newest 20 messages", () => {
|
||||
const target = createMessageQueueTarget("session-1", "/repo", "runtime-a")!
|
||||
for (let index = 0; index < 25; index += 1) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from 'zod';
|
||||
import type { Event } from '@opencode-ai/sdk/v2';
|
||||
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
|
||||
import type { AttachedFile } from './types/sessionTypes';
|
||||
import { contextPartMetadataSchema, type ContextPartMetadata } from '@/lib/messages/contextParts';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
@@ -59,10 +60,43 @@ export interface QueuedMessageSendConfig {
|
||||
variant?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context captured with a queued message: whatever the composer had attached
|
||||
* when the message was queued. It leaves the composer with the message, so
|
||||
* delivery (by the server, or by the auto-send hook in VS Code) carries it and
|
||||
* editing the message brings it back.
|
||||
*/
|
||||
export type QueuedContextPart =
|
||||
| {
|
||||
/** An attached context item: a draft chip or a linked issue/PR. Restored on edit. */
|
||||
kind: 'context';
|
||||
text: string;
|
||||
metadata: ContextPartMetadata;
|
||||
/** Delivered as its own synthetic part right before this one (a linked PR's reading instructions). */
|
||||
instructions?: string;
|
||||
}
|
||||
| {
|
||||
/** Derived from the message text (the skill instruction); re-derived when the text is sent again, so never restored. */
|
||||
kind: 'instruction';
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
/** Handed to the composer by another surface (conflict resolution); restored as pending on edit. */
|
||||
kind: 'synthetic';
|
||||
text: string;
|
||||
};
|
||||
|
||||
export interface QueuedMessage {
|
||||
id: string;
|
||||
/** What the user typed, for display and editing. */
|
||||
content: string;
|
||||
/** What is delivered: `content` without its leading agent mention, file mentions already resolved. */
|
||||
text: string;
|
||||
/** Agent mentioned at the start of `content`, delivered as an agent part. */
|
||||
agentMention?: string;
|
||||
attachments?: AttachedFile[];
|
||||
/** Absent on a server projection item; a take brings it back. */
|
||||
context?: QueuedContextPart[];
|
||||
createdAt: number;
|
||||
/** Send config captured at queue time — used as-is when auto-sending */
|
||||
sendConfig?: QueuedMessageSendConfig;
|
||||
@@ -70,12 +104,12 @@ export interface QueuedMessage {
|
||||
|
||||
interface QueuedMessageInput {
|
||||
content: string;
|
||||
attachments?: AttachedFile[];
|
||||
sendConfig?: QueuedMessageSendConfig;
|
||||
/** Text to deliver once the agent mention is stripped; defaults to `content`. */
|
||||
/** Defaults to `content`. */
|
||||
text?: string;
|
||||
/** Agent mentioned at the start of `content`, delivered as an agent part. */
|
||||
agentMention?: string;
|
||||
attachments?: AttachedFile[];
|
||||
context?: QueuedContextPart[];
|
||||
sendConfig?: QueuedMessageSendConfig;
|
||||
}
|
||||
|
||||
export type MessageQueueTarget = {
|
||||
@@ -127,12 +161,26 @@ const serverAttachmentSchema = z.object({
|
||||
dataUrl: z.string().optional(),
|
||||
});
|
||||
|
||||
const serverContextPartSchema = z.discriminatedUnion('kind', [
|
||||
z.object({
|
||||
kind: z.literal('context'),
|
||||
text: z.string(),
|
||||
metadata: contextPartMetadataSchema,
|
||||
instructions: z.string().optional(),
|
||||
}),
|
||||
z.object({ kind: z.literal('instruction'), text: z.string() }),
|
||||
z.object({ kind: z.literal('synthetic'), text: z.string() }),
|
||||
]);
|
||||
|
||||
const serverItemSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
createdAt: z.number(),
|
||||
content: z.string(),
|
||||
text: z.string(),
|
||||
agentMention: z.string().optional(),
|
||||
attachments: z.array(serverAttachmentSchema),
|
||||
/** Present only on a taken item; broadcasts and snapshots omit it like attachment payloads. */
|
||||
context: z.array(serverContextPartSchema).optional(),
|
||||
sendConfig: serverSendConfigSchema,
|
||||
});
|
||||
|
||||
@@ -199,13 +247,19 @@ const toAttachedFile = (attachment: ServerQueueAttachment): AttachedFile => {
|
||||
return file;
|
||||
};
|
||||
|
||||
const toQueuedMessage = (item: ServerQueueItem): QueuedMessage => ({
|
||||
id: item.id,
|
||||
content: item.content,
|
||||
createdAt: item.createdAt,
|
||||
attachments: item.attachments.length > 0 ? item.attachments.map(toAttachedFile) : undefined,
|
||||
sendConfig: { ...item.sendConfig },
|
||||
});
|
||||
const toQueuedMessage = (item: ServerQueueItem): QueuedMessage => {
|
||||
const message: QueuedMessage = {
|
||||
id: item.id,
|
||||
content: item.content,
|
||||
text: item.text,
|
||||
createdAt: item.createdAt,
|
||||
sendConfig: { ...item.sendConfig },
|
||||
};
|
||||
if (item.agentMention) message.agentMention = item.agentMention;
|
||||
if (item.attachments.length > 0) message.attachments = item.attachments.map(toAttachedFile);
|
||||
if (item.context) message.context = item.context;
|
||||
return message;
|
||||
};
|
||||
|
||||
type ServerQueueAttachmentInput = Omit<ServerQueueAttachment, 'dataUrl'> & { dataUrl: string };
|
||||
|
||||
@@ -214,6 +268,7 @@ type ServerQueueItemInput = {
|
||||
text: string;
|
||||
agentMention?: string;
|
||||
attachments: ServerQueueAttachmentInput[];
|
||||
context: QueuedContextPart[];
|
||||
sendConfig: QueuedMessageSendConfig;
|
||||
};
|
||||
|
||||
@@ -240,6 +295,7 @@ const toServerItemInput = (message: QueuedMessageInput, sendConfig: QueuedMessag
|
||||
content: message.content,
|
||||
text: message.text ?? message.content,
|
||||
attachments: (message.attachments ?? []).filter((file) => Boolean(file.dataUrl)).map(toServerAttachment),
|
||||
context: message.context ?? [],
|
||||
sendConfig,
|
||||
};
|
||||
if (message.agentMention) item.agentMention = message.agentMention;
|
||||
@@ -327,22 +383,32 @@ interface MessageQueueActions {
|
||||
|
||||
type MessageQueueStore = MessageQueueState & MessageQueueActions;
|
||||
|
||||
/** Messages persisted before version 3 carried only `content`. */
|
||||
type PersistedQueuedMessage = Omit<QueuedMessage, 'text'> & { text?: string };
|
||||
|
||||
type PersistedMessageQueueState = {
|
||||
queuedMessages?: Record<string, QueuedMessage[]>;
|
||||
quarantinedLegacyMessages?: Record<string, QueuedMessage[]>;
|
||||
queuedMessages?: Record<string, PersistedQueuedMessage[]>;
|
||||
quarantinedLegacyMessages?: Record<string, PersistedQueuedMessage[]>;
|
||||
followUpBehavior?: FollowUpBehavior;
|
||||
queueModeEnabled?: boolean;
|
||||
};
|
||||
|
||||
const withDeliveryText = (queues: Record<string, PersistedQueuedMessage[]>): Record<string, QueuedMessage[]> => (
|
||||
Object.fromEntries(Object.entries(queues).map(([key, queue]) => [
|
||||
key,
|
||||
queue.map((message) => ({ ...message, text: message.text ?? message.content })),
|
||||
]))
|
||||
);
|
||||
|
||||
export const migrateMessageQueueState = (persistedState: unknown, version: number): Partial<MessageQueueStore> => {
|
||||
const state = (persistedState ?? {}) as PersistedMessageQueueState;
|
||||
const legacyQueues = version < 2 ? (state.queuedMessages ?? {}) : {};
|
||||
return {
|
||||
queuedMessages: version < 2 ? {} : (state.queuedMessages ?? {}),
|
||||
quarantinedLegacyMessages: {
|
||||
queuedMessages: version < 2 ? {} : withDeliveryText(state.queuedMessages ?? {}),
|
||||
quarantinedLegacyMessages: withDeliveryText({
|
||||
...(state.quarantinedLegacyMessages ?? {}),
|
||||
...legacyQueues,
|
||||
},
|
||||
}),
|
||||
followUpBehavior: normalizeFollowUpBehavior(state.followUpBehavior, state.queueModeEnabled ?? null),
|
||||
};
|
||||
};
|
||||
@@ -424,10 +490,13 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
|
||||
const queuedMessage: QueuedMessage = {
|
||||
id,
|
||||
content: message.content,
|
||||
attachments: message.attachments,
|
||||
text: message.text ?? message.content,
|
||||
createdAt: Date.now(),
|
||||
sendConfig: message.sendConfig,
|
||||
};
|
||||
if (message.agentMention) queuedMessage.agentMention = message.agentMention;
|
||||
if (message.attachments && message.attachments.length > 0) queuedMessage.attachments = message.attachments;
|
||||
if (message.context && message.context.length > 0) queuedMessage.context = message.context;
|
||||
|
||||
set((state) => {
|
||||
const currentQueue = state.queuedMessages[key] ?? [];
|
||||
@@ -694,7 +763,7 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
|
||||
},
|
||||
{
|
||||
name: 'message-queue-store',
|
||||
version: 2,
|
||||
version: 3,
|
||||
storage: createDeferredSafeJSONStorage(),
|
||||
partialize: (state) => ({
|
||||
queuedMessages: Object.fromEntries(
|
||||
|
||||
@@ -284,7 +284,7 @@ Rules:
|
||||
2. If an action targets a session by ID, resolve the **session's own directory**. Do not assume the current directory is correct.
|
||||
3. `session-ui-store.ts` should delegate to `session-actions.ts` for these mutations instead of duplicating SDK calls.
|
||||
4. Sending after a revert commits the new branch optimistically: remove the reverted tail and marker before inserting the new message, and restore both if the send is rejected.
|
||||
5. Composer and queued sends carry their captured runtime, directory, and session through asynchronous preparation. A runtime change cancels the send instead of re-resolving it against the new runtime. Outside VS Code the queue itself is server-owned (`packages/web/server/lib/message-queue/`): the UI hands the server the captured send configuration at queue time and the server delivers on idle; the composer only sends a queued message itself after taking it back from the server (`takeForSend`). See the `messageQueueStore.ts` section in `stores/DOCUMENTATION.md`.
|
||||
5. Composer and queued sends carry their captured runtime, directory, and session through asynchronous preparation. A runtime change cancels the send instead of re-resolving it against the new runtime. Outside VS Code the queue itself is server-owned (`packages/web/server/lib/message-queue/`): the UI hands the server the captured send configuration, resolved text, attachments, and attached context at queue time and the server delivers on idle; the composer only sends a queued message itself after taking it back from the server (`takeForSend`). See the `messageQueueStore.ts` section in `stores/DOCUMENTATION.md`.
|
||||
6. After session creation, the directory returned by the server is authoritative over the requested draft directory. The server may canonicalize a worktree path, and the first prompt must use the same directory identity as the created session.
|
||||
7. Regular new-chat drafts that inherit the persisted current/last directory must not create a session against a confirmed-missing path. Fall back to the active project only when OpenCode reports the directory missing; keep explicit worktree targets, in-flight worktree creation, and unknown/offline probes unchanged, and do not persist the fallback until session creation succeeds. A concurrent draft rewrite to that same active-project fallback must not abort session creation.
|
||||
8. A prompt send that fails **after** the request left the client is ambiguous, never a definite failure: the server may already be answering it. Transports tag those errors (`markAmbiguousTransportFailure` in `@/lib/relay/transport-error`; the relay tunnel tags every stream that dies with a request in flight), and `isAmbiguousSendFailure` reads the tag before falling back to status/text heuristics. An ambiguous failure waits for the connection to return, refetches recent messages, and confirms the optimistic message in place instead of rolling it back — rolling it back lets the message queue re-send a prompt the engine is already running, producing two independent AI responses for one user message.
|
||||
|
||||
@@ -29,8 +29,8 @@ describe('cleanupPersistedSessionState', () => {
|
||||
// way a server snapshot would, and expect only the projection to go.
|
||||
useMessageQueueStore.setState({
|
||||
queuedMessages: {
|
||||
[getMessageQueueKey(deleted)]: [{ id: 'q-delete', content: 'delete', createdAt: 1 }],
|
||||
[getMessageQueueKey(retained)]: [{ id: 'q-retain', content: 'retain', createdAt: 1 }],
|
||||
[getMessageQueueKey(deleted)]: [{ id: 'q-delete', content: 'delete', text: 'delete', createdAt: 1 }],
|
||||
[getMessageQueueKey(retained)]: [{ id: 'q-retain', content: 'retain', text: 'retain', createdAt: 1 }],
|
||||
},
|
||||
});
|
||||
useTodosPersistStore.getState().setSessionTodos('/repo-a', 'session-1', [todo]);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user