Merge remote-tracking branch 'origin/main' into fix/ui-thinking-effort-draft-project-rename
This commit is contained in:
@@ -6,13 +6,13 @@
|
||||
[](https://www.patreon.com/openchamber)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 🏖️ I'm on vacation from 31 Aug to 4 Sep. I'll review issues and PRs when I'm back. Thanks for your patience.
|
||||
> I'm on vacation from 31 Aug to 4 Sep. I'll review issues and PRs when I'm back. Thanks for your patience.
|
||||
|
||||
## Run agent work. Keep control. Ship from anywhere.
|
||||
|
||||
**OpenChamber is an open-source workspace for running, supervising, and reviewing AI coding work across desktop, browser, editor, and mobile.**
|
||||
**OpenChamber is an open-source workspace for running and reviewing AI coding work on desktop, web, VS Code, and mobile.**
|
||||
|
||||
OpenChamber gives you one place to direct agent work, understand the changes, and move them toward release. Your projects stay available when you switch devices or step away.
|
||||
Start agent work, see what changed, and take it through review and release. Your projects and sessions remain available when you switch devices or step away.
|
||||
|
||||

|
||||
|
||||
@@ -32,7 +32,7 @@ OpenChamber gives you one place to direct agent work, understand the changes, an
|
||||
|
||||
### Goals that continue on their own
|
||||
|
||||
Give a session a finish line with **Session Goals**. OpenChamber checks the result after every turn and keeps the agent working until the goal is complete, blocked, or reaches the limit you set — even after you close the app.
|
||||
Give a session a finish line with **Session Goals**. OpenChamber checks the result after every turn and keeps the agent working until it completes the goal, gets blocked, or reaches the limit you set. It can continue after you close the app.
|
||||
|
||||
### Compare and combine runs
|
||||
|
||||
@@ -44,7 +44,7 @@ Use **Multi-run** to give the same task to up to five models, each in its own se
|
||||
|
||||
### Inspect a running app
|
||||
|
||||
Open your app beside the conversation with **Preview**. Point at an element and send the agent its screenshot, styles, position, and browser errors — all the context behind “this thing here.” Desktop brings the same workflow to any web page through its built-in browser.
|
||||
Open your app beside the conversation with **Preview**. Point at an element to send the agent its screenshot, styles, position, and browser errors. No more trying to explain "this thing here." The desktop app can do the same with any web page in its built-in browser.
|
||||
|
||||
### GitHub context from issue to pull request
|
||||
|
||||
@@ -78,7 +78,7 @@ Run a prompt once, daily, weekly, or on a cron schedule. Scheduled tasks can use
|
||||
|
||||
## Quick start
|
||||
|
||||
### Desktop — macOS, Windows, and Linux
|
||||
### Desktop for macOS, Windows, and Linux
|
||||
|
||||
Download the latest release from [GitHub Releases](https://github.com/openchamber/openchamber/releases/latest). Desktop bundles the matching OpenCode CLI, so no separate OpenCode installation is required.
|
||||
|
||||
@@ -93,9 +93,9 @@ Linux AppImages require FUSE (`libfuse.so.2`). Without FUSE, run with `APPIMAGE_
|
||||
|
||||
### VS Code
|
||||
|
||||
Install [OpenChamber from the Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=fedaykindev.openchamber), or search for “OpenChamber” in Extensions.
|
||||
Install [OpenChamber from the Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=fedaykindev.openchamber), or search for "OpenChamber" in Extensions.
|
||||
|
||||
### CLI — Web and PWA
|
||||
### CLI for Web and PWA
|
||||
|
||||
Requires Node.js 22+. CLI/Web and VS Code use your installed [OpenCode CLI](https://opencode.ai).
|
||||
|
||||
@@ -139,9 +139,9 @@ For self-hosting details, see the [reverse proxy guide](docs/REVERSE_PROXY.md).
|
||||
|
||||
## Why OpenCode?
|
||||
|
||||
OpenChamber uses [OpenCode](https://opencode.ai) to power its coding agents. We chose it because we believe it provides the best open-source agentic coding experience today: capable, extensible, and open by design.
|
||||
OpenChamber uses [OpenCode](https://opencode.ai) to run coding agents. We chose it because it is open source, has a solid API, and is easy to extend.
|
||||
|
||||
Around that foundation, OpenChamber brings together the work that happens before, during, and after an agent run — deciding what to try, keeping it on track, reviewing the result, connecting from anywhere, and getting the change shipped.
|
||||
OpenChamber handles the rest of the workflow. You can decide what to try, keep the agent on track, review the result, connect from another device, and ship the change.
|
||||
|
||||
OpenChamber is an independent project and is not affiliated with the OpenCode team.
|
||||
|
||||
@@ -153,11 +153,11 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for development setup and contribution
|
||||
|
||||
Special thanks to:
|
||||
|
||||
- [OpenCode](https://opencode.ai) for its excellent API and extensible open-source architecture
|
||||
- [Pierre](https://pierrejs-docs.vercel.app/) for its fast diff viewer and syntax highlighting
|
||||
- [OpenCode](https://opencode.ai) for the API and open-source architecture OpenChamber builds on
|
||||
- [Pierre](https://pierrejs-docs.vercel.app/) for the diff viewer and syntax highlighting
|
||||
- [Ghostty-web](https://github.com/coder/ghostty-web) for its Ghostty web renderer
|
||||
- [Yulia Ivashko](https://github.com/yulia-ivashko), who built the firework celebration that plays on every successful push
|
||||
- Every contributor who shaped OpenChamber with code, ideas, and attention to detail
|
||||
- Everyone who contributed code, reported bugs, or shared ideas
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
|
||||
import { usePwaManifestSync } from '@/hooks/usePwaManifestSync';
|
||||
import { useMessageQueueHoldSync } from '@/hooks/useMessageQueueHoldSync';
|
||||
import { useQueuedMessageAutoSend } from '@/hooks/useQueuedMessageAutoSend';
|
||||
import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup';
|
||||
import { useWindowControlsOverlayLayout } from '@/hooks/useWindowControlsOverlayLayout';
|
||||
@@ -8,6 +9,7 @@ import { setOptimisticRefs } from '@/sync/session-actions';
|
||||
import { markSessionViewed } from '@/sync/notification-store';
|
||||
import { setExternallyViewedSession } from '@/sync/sync-context';
|
||||
import { useSync } from '@/sync/use-sync';
|
||||
import { isServerOwnedMessageQueue } from '@/stores/messageQueueStore';
|
||||
|
||||
const MINI_CHAT_PRESENCE_CHANNEL = 'openchamber:mini-chat-presence';
|
||||
|
||||
@@ -66,7 +68,10 @@ export function SyncRuntimeEffects({ embeddedBackgroundWorkEnabled }: {
|
||||
embeddedBackgroundWorkEnabled: boolean;
|
||||
}) {
|
||||
useSessionAutoCleanup(embeddedBackgroundWorkEnabled);
|
||||
useQueuedMessageAutoSend(embeddedBackgroundWorkEnabled);
|
||||
// Web, desktop, and mobile hand the queue to the OpenChamber server, which
|
||||
// delivers it with or without a UI; only VS Code still sends from the UI.
|
||||
useQueuedMessageAutoSend(embeddedBackgroundWorkEnabled && !isServerOwnedMessageQueue());
|
||||
useMessageQueueHoldSync();
|
||||
|
||||
return <SyncOptimisticBridge />;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useProjectContextStore } from '@/stores/useProjectContextStore';
|
||||
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
|
||||
import { usePermissionStore } from '@/stores/permissionStore';
|
||||
import { useMessageQueueStore } from '@/stores/messageQueueStore';
|
||||
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
||||
import { useGitStore } from '@/stores/useGitStore';
|
||||
import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
||||
@@ -70,6 +71,7 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD
|
||||
// restarts the resume window so the switch is treated as a fresh load.
|
||||
resetSessionActivityTiming();
|
||||
usePermissionStore.getState().reset();
|
||||
useMessageQueueStore.getState().resetForRuntimeSwitch(detail.previousRuntimeKey);
|
||||
useFileSearchStore.getState().resetForRuntimeSwitch();
|
||||
useGitStore.getState().resetForRuntimeSwitch(detail.runtimeKey);
|
||||
useGitHubPrStatusStore.getState().resetForRuntimeSwitch();
|
||||
|
||||
@@ -186,7 +186,6 @@ const MAX_MOBILE_COMPOSER_LINES = 16;
|
||||
*/
|
||||
const MOBILE_COMPOSER_BOUND_GAP_PX = 4;
|
||||
const EMPTY_QUEUE: QueuedMessage[] = [];
|
||||
const EMPTY_SENDING_IDS: string[] = [];
|
||||
const COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH = 560;
|
||||
const renameFileForAttachmentCitation = (file: File, filename: string): File => {
|
||||
if (file.name === filename) {
|
||||
@@ -774,8 +773,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
)
|
||||
);
|
||||
const addToQueue = useMessageQueueStore((state) => state.addToQueue);
|
||||
const clearQueue = useMessageQueueStore((state) => state.clearQueue);
|
||||
const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue);
|
||||
const takeForSend = useMessageQueueStore((state) => state.takeForSend);
|
||||
|
||||
// Inline comment drafts
|
||||
const inlineDraftSessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : '');
|
||||
@@ -929,9 +927,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
// queue consumes them and attaches them as structured context parts.
|
||||
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);
|
||||
|
||||
addToQueue(messageQueueTarget, {
|
||||
content: messageToQueue,
|
||||
text: sanitizedText,
|
||||
agentMention: mention?.name,
|
||||
attachments: attachmentsToQueue.length > 0 ? attachmentsToQueue : undefined,
|
||||
sendConfig: currentProviderId && currentModelId ? {
|
||||
providerID: currentProviderId,
|
||||
@@ -939,6 +943,20 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
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]);
|
||||
}
|
||||
});
|
||||
|
||||
// Sending while the agent works must still take the reader to the
|
||||
@@ -959,7 +977,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
if (!isMobile) {
|
||||
composerRef.current?.focus();
|
||||
}
|
||||
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, currentProviderId, currentModelId, currentAgentName, currentVariant, scrollToLatest]);
|
||||
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, currentProviderId, currentModelId, currentAgentName, currentVariant, scrollToLatest, agents, t]);
|
||||
|
||||
const handleQueuedMessageEdit = React.useCallback((content: string) => {
|
||||
setMessage(content);
|
||||
@@ -1023,28 +1041,23 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
hasContent: options.presetText.trim().length > 0 || attachedFiles.length > 0 || hasDrafts,
|
||||
}
|
||||
: getCurrentInputSnapshot();
|
||||
// A queued item stays in the queue until its own send resolves, so the
|
||||
// auto-send hook may already be delivering one of these. Merging it here
|
||||
// would send the same message twice (the window is seconds over a relay).
|
||||
const sendingIds = messageQueueTarget
|
||||
? useMessageQueueStore.getState().sendingIds[getMessageQueueKey(messageQueueTarget)] ?? EMPTY_SENDING_IDS
|
||||
: EMPTY_SENDING_IDS;
|
||||
const queuedMessagesToSend = (queuedMessageId
|
||||
? queuedMessages.filter((message) => message.id === queuedMessageId)
|
||||
: queuedMessages
|
||||
).filter((message) => !sendingIds.includes(message.id));
|
||||
|
||||
if (queuedOnly && autoReviewRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (queuedOnly) {
|
||||
if (queuedMessagesToSend.length === 0 || !currentSessionId) return;
|
||||
if (!queuedMessages.some((message) => !queuedMessageId || message.id === queuedMessageId) || !currentSessionId) return;
|
||||
} else if ((!inputSnapshot.hasContent && !hasQueuedMessages) || (!currentSessionId && !newSessionDraftOpen)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const capturedSendConfig = queuedOnly ? queuedMessagesToSend[0]?.sendConfig : undefined;
|
||||
// The projection knows the captured send configuration; the full
|
||||
// messages are taken from the queue only once nothing below can still
|
||||
// bail out, so an early return leaves the queue untouched.
|
||||
const queuedProjection = queuedMessageId
|
||||
? queuedMessages.filter((message) => message.id === queuedMessageId)
|
||||
: queuedMessages;
|
||||
const capturedSendConfig = queuedOnly ? queuedProjection[0]?.sendConfig : undefined;
|
||||
const providerIdToSend = capturedSendConfig?.providerID ?? currentProviderId;
|
||||
const modelIdToSend = capturedSendConfig?.modelID ?? currentModelId;
|
||||
const agentNameToSend = capturedSendConfig?.agent ?? currentAgentName;
|
||||
@@ -1115,10 +1128,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
const preparedDocumentMentions = new Map<string, AttachedFile[]>();
|
||||
const reservedFilenames = new Set([
|
||||
...attachedFiles.map((attachment) => attachment.filename),
|
||||
...queuedMessagesToSend.flatMap((queued) => queued.attachments?.map((attachment) => attachment.filename) ?? []),
|
||||
...queuedProjection.flatMap((queued) => queued.attachments?.map((attachment) => attachment.filename) ?? []),
|
||||
]);
|
||||
const mentionTexts = [
|
||||
...queuedMessagesToSend.map((queued) => queued.content),
|
||||
...queuedProjection.map((queued) => queued.content),
|
||||
...(!queuedOnly && inputSnapshot.hasContent ? [inputSnapshot.message] : []),
|
||||
];
|
||||
for (const rawText of mentionTexts) {
|
||||
@@ -1150,6 +1163,22 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// skips anything already in flight, and a message already being
|
||||
// delivered stays out of this send so it cannot go out twice.
|
||||
let queuedMessagesToSend: QueuedMessage[] = [];
|
||||
if (capturedTarget && hasQueuedMessages) {
|
||||
try {
|
||||
queuedMessagesToSend = await takeForSend(capturedTarget, queuedMessageId);
|
||||
} catch (error) {
|
||||
console.warn('[queue] failed to take queued messages for sending:', error);
|
||||
toast.error(t('chat.queuedMessage.toast.takeFailed'));
|
||||
return;
|
||||
}
|
||||
if (queuedOnly && queuedMessagesToSend.length === 0) return;
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -1201,12 +1230,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
|
||||
if (outgoing.isEmpty) return;
|
||||
|
||||
// Clear queue and input
|
||||
if (capturedTarget && queuedMessageId) {
|
||||
removeFromQueue(capturedTarget, queuedMessageId);
|
||||
} else if (capturedTarget && hasQueuedMessages) {
|
||||
clearQueue(capturedTarget);
|
||||
}
|
||||
// Clear input (the queue was taken above)
|
||||
if (!queuedOnly) {
|
||||
setMessage('');
|
||||
confirmedMentionsRef.current.clear();
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useInputStore } from '@/sync/input-store';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/components/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface QueuedMessageChipProps {
|
||||
@@ -149,16 +150,21 @@ export const QueuedMessageChips = memo(({ onEditMessage, onSendMessage }: Queued
|
||||
|
||||
const handleEdit = React.useCallback((message: QueuedMessage) => {
|
||||
if (!target) return;
|
||||
|
||||
const popped = popToInput(target, message.id);
|
||||
if (popped) {
|
||||
|
||||
// The full message (attachments included) comes back from the queue's
|
||||
// owner; the chip itself only knows the summary.
|
||||
void popToInput(target, message.id).then((popped) => {
|
||||
if (!popped) return;
|
||||
if (popped.attachments && popped.attachments.length > 0) {
|
||||
const currentAttachments = useInputStore.getState().attachedFiles;
|
||||
useInputStore.getState().setAttachedFiles([...currentAttachments, ...popped.attachments]);
|
||||
}
|
||||
onEditMessage(popped.content, popped.attachments);
|
||||
}
|
||||
}, [target, popToInput, onEditMessage]);
|
||||
}).catch((error) => {
|
||||
console.warn('[queue] failed to take queued message for editing:', error);
|
||||
toast.error(t('chat.queuedMessage.toast.takeFailed'));
|
||||
});
|
||||
}, [target, popToInput, onEditMessage, t]);
|
||||
|
||||
const handleSend = React.useCallback((message: QueuedMessage) => {
|
||||
onSendMessage(message.id);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
|
||||
import { isServerOwnedMessageQueue, useMessageQueueStore } from '@/stores/messageQueueStore';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
|
||||
// Server holds expire on their own (the UI that asserted them may be gone);
|
||||
// re-assert well inside that window while a run is still going.
|
||||
const REASSERT_INTERVAL_MS = 2 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Auto-review is driven from the UI: it forwards the implementer's answer to
|
||||
* the reviewer and back while the original session bounces through idle. The
|
||||
* queue owner must not deliver into those gaps, so while a run is going the
|
||||
* server is told to hold that session's queue, and released when it ends.
|
||||
*/
|
||||
export function useMessageQueueHoldSync(): void {
|
||||
const runs = useAutoReviewStore((state) => state.runsByOriginalSessionID);
|
||||
const heldRef = React.useRef<Set<string>>(new Set());
|
||||
|
||||
const running = React.useMemo(() => {
|
||||
if (!isServerOwnedMessageQueue()) return new Set<string>();
|
||||
const runtimeKey = getRuntimeKey();
|
||||
return new Set(
|
||||
Object.values(runs)
|
||||
.filter((run) => run.status === 'running' && run.runtimeKey === runtimeKey)
|
||||
.map((run) => run.originalSessionID),
|
||||
);
|
||||
}, [runs]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const setHold = (sessionId: string, held: boolean) => {
|
||||
useMessageQueueStore.getState().setServerHold(sessionId, held).catch((error) => {
|
||||
console.warn(`[queue] failed to ${held ? 'hold' : 'release'} the queue for ${sessionId}:`, error);
|
||||
});
|
||||
};
|
||||
|
||||
for (const sessionId of heldRef.current) {
|
||||
if (!running.has(sessionId)) {
|
||||
heldRef.current.delete(sessionId);
|
||||
setHold(sessionId, false);
|
||||
}
|
||||
}
|
||||
for (const sessionId of running) {
|
||||
if (!heldRef.current.has(sessionId)) {
|
||||
heldRef.current.add(sessionId);
|
||||
setHold(sessionId, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (running.size === 0) return;
|
||||
const interval = setInterval(() => {
|
||||
for (const sessionId of running) setHold(sessionId, true);
|
||||
}, REASSERT_INTERVAL_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, [running]);
|
||||
|
||||
React.useEffect(() => () => {
|
||||
const held = heldRef.current;
|
||||
for (const sessionId of held) {
|
||||
void useMessageQueueStore.getState().setServerHold(sessionId, false).catch(() => undefined);
|
||||
}
|
||||
held.clear();
|
||||
}, []);
|
||||
}
|
||||
@@ -2003,6 +2003,8 @@ export const dict = {
|
||||
'chat.queuedMessage.send': 'senden',
|
||||
'chat.queuedMessage.removeAria': 'Aus der Warteschlange entfernen',
|
||||
'chat.queuedMessage.reorderAria': 'Ziehen, um neu anzuordnen',
|
||||
'chat.queuedMessage.toast.queueFailed': 'Die Nachricht konnte nicht in die Warteschlange gestellt werden. Sie ist wieder im Eingabefeld.',
|
||||
'chat.queuedMessage.toast.takeFailed': 'Die Nachricht aus der Warteschlange konnte nicht geladen werden. Bitte erneut versuchen.',
|
||||
'chat.container.returnToParent.aria': 'Zurück zur übergeordneten Sitzung',
|
||||
'chat.container.returnToParent.titleNamed': 'Zurück zu: {title}',
|
||||
'chat.container.returnToParent.title': 'Zurück zur übergeordneten Sitzung',
|
||||
|
||||
@@ -2208,6 +2208,8 @@ export const dict = {
|
||||
'chat.queuedMessage.send': 'send',
|
||||
'chat.queuedMessage.removeAria': 'Remove from queue',
|
||||
'chat.queuedMessage.reorderAria': 'Drag to reorder',
|
||||
'chat.queuedMessage.toast.queueFailed': 'Couldn\'t queue the message. It\'s back in the composer.',
|
||||
'chat.queuedMessage.toast.takeFailed': 'Couldn\'t load the queued message. Please try again.',
|
||||
'chat.container.returnToParent.aria': 'Return to parent session',
|
||||
'chat.container.returnToParent.titleNamed': 'Return to: {title}',
|
||||
'chat.container.returnToParent.title': 'Return to parent session',
|
||||
|
||||
@@ -2186,6 +2186,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.queuedMessage.send": "send",
|
||||
"chat.queuedMessage.removeAria": "Eliminar de la cola",
|
||||
"chat.queuedMessage.reorderAria": "Arrastra para reordenar",
|
||||
"chat.queuedMessage.toast.queueFailed": "No se pudo poner el mensaje en cola. Vuelve a estar en el editor.",
|
||||
"chat.queuedMessage.toast.takeFailed": "No se pudo cargar el mensaje en cola. Inténtalo de nuevo.",
|
||||
"chat.container.returnToParent.aria": "Volver a la sesión principal",
|
||||
"chat.container.returnToParent.titleNamed": "Volver a: {title}",
|
||||
"chat.container.returnToParent.title": "Volver a la sesión principal",
|
||||
|
||||
@@ -1938,6 +1938,8 @@ export const dict = {
|
||||
'chat.queuedMessage.send': 'envoyer',
|
||||
'chat.queuedMessage.removeAria': 'Supprimer de la file d\'attente',
|
||||
'chat.queuedMessage.reorderAria': 'Glisser pour réorganiser',
|
||||
'chat.queuedMessage.toast.queueFailed': 'Impossible de mettre le message en file d\'attente. Il est de retour dans l\'éditeur.',
|
||||
'chat.queuedMessage.toast.takeFailed': 'Impossible de charger le message en file d\'attente. Veuillez réessayer.',
|
||||
'chat.container.returnToParent.aria': 'Retour à la session parents',
|
||||
'chat.container.returnToParent.titleNamed': 'Retourner à : {title}',
|
||||
'chat.container.returnToParent.title': 'Retour à la session parents',
|
||||
|
||||
@@ -2204,6 +2204,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.queuedMessage.send': '送信',
|
||||
'chat.queuedMessage.removeAria': 'キューから削除',
|
||||
'chat.queuedMessage.reorderAria': 'ドラッグして並び替え',
|
||||
'chat.queuedMessage.toast.queueFailed': 'メッセージをキューに追加できませんでした。入力欄に戻しました。',
|
||||
'chat.queuedMessage.toast.takeFailed': 'キューのメッセージを読み込めませんでした。もう一度お試しください。',
|
||||
'chat.container.returnToParent.aria': '親セッションに戻る',
|
||||
'chat.container.returnToParent.titleNamed': '戻る: {title}',
|
||||
'chat.container.returnToParent.title': '親セッションに戻る',
|
||||
|
||||
@@ -2210,6 +2210,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.queuedMessage.send': 'send',
|
||||
'chat.queuedMessage.removeAria': '큐에서 제거',
|
||||
'chat.queuedMessage.reorderAria': '드래그하여 순서 변경',
|
||||
'chat.queuedMessage.toast.queueFailed': '메시지를 대기열에 추가하지 못했습니다. 입력창으로 되돌렸습니다.',
|
||||
'chat.queuedMessage.toast.takeFailed': '대기열의 메시지를 불러오지 못했습니다. 다시 시도해 주세요.',
|
||||
'chat.container.returnToParent.aria': '상위 세션으로 돌아가기',
|
||||
'chat.container.returnToParent.titleNamed': '돌아가기: {title}',
|
||||
'chat.container.returnToParent.title': '상위 세션으로 돌아가기',
|
||||
|
||||
@@ -863,6 +863,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.queuedMessage.send': 'send',
|
||||
'chat.queuedMessage.removeAria': 'Usuń z kolejki',
|
||||
'chat.queuedMessage.reorderAria': 'Przeciągnij, aby zmienić kolejność',
|
||||
'chat.queuedMessage.toast.queueFailed': 'Nie udało się dodać wiadomości do kolejki. Wróciła do pola wpisywania.',
|
||||
'chat.queuedMessage.toast.takeFailed': 'Nie udało się wczytać wiadomości z kolejki. Spróbuj ponownie.',
|
||||
'chat.container.returnToParent.aria': 'Powrót do sesji nadrzędnej',
|
||||
'chat.container.returnToParent.titleNamed': 'Powrót do: {title}',
|
||||
'chat.container.returnToParent.title': 'Powrót do sesji nadrzędnej',
|
||||
|
||||
@@ -2186,6 +2186,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.queuedMessage.send": "send",
|
||||
"chat.queuedMessage.removeAria": "Excluir da fila",
|
||||
"chat.queuedMessage.reorderAria": "Arraste para reordenar",
|
||||
"chat.queuedMessage.toast.queueFailed": "Não foi possível enfileirar a mensagem. Ela voltou para o campo de texto.",
|
||||
"chat.queuedMessage.toast.takeFailed": "Não foi possível carregar a mensagem da fila. Tente novamente.",
|
||||
"chat.container.returnToParent.aria": "Voltar para a sessão principal",
|
||||
"chat.container.returnToParent.titleNamed": "Voltar para: {title}",
|
||||
"chat.container.returnToParent.title": "Voltar para a sessão principal",
|
||||
|
||||
@@ -2167,6 +2167,8 @@ export const dict = {
|
||||
'chat.queuedMessage.send': 'gönder',
|
||||
'chat.queuedMessage.removeAria': 'Kuyruktan kaldır',
|
||||
'chat.queuedMessage.reorderAria': 'Yeniden sıralamak için sürükle',
|
||||
'chat.queuedMessage.toast.queueFailed': 'Mesaj kuyruğa eklenemedi. Yazma alanına geri kondu.',
|
||||
'chat.queuedMessage.toast.takeFailed': 'Kuyruktaki mesaj yüklenemedi. Lütfen tekrar deneyin.',
|
||||
'chat.container.returnToParent.aria': 'Üst session\'a dön',
|
||||
'chat.container.returnToParent.titleNamed': 'Şuraya dön: {title}',
|
||||
'chat.container.returnToParent.title': 'Üst session\'a dön',
|
||||
|
||||
@@ -2186,6 +2186,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.queuedMessage.send": "send",
|
||||
"chat.queuedMessage.removeAria": "Видалити з черги",
|
||||
"chat.queuedMessage.reorderAria": "Перетягніть, щоб змінити порядок",
|
||||
"chat.queuedMessage.toast.queueFailed": "Не вдалося додати повідомлення в чергу. Воно повернулося в поле вводу.",
|
||||
"chat.queuedMessage.toast.takeFailed": "Не вдалося завантажити повідомлення з черги. Спробуйте ще раз.",
|
||||
"chat.container.returnToParent.aria": "Повернутися до батьківської сесії",
|
||||
"chat.container.returnToParent.titleNamed": "Повернутися до: {title}",
|
||||
"chat.container.returnToParent.title": "Повернутися до батьківської сесії",
|
||||
|
||||
@@ -2174,6 +2174,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.queuedMessage.send': 'send',
|
||||
'chat.queuedMessage.removeAria': '从队列移除',
|
||||
'chat.queuedMessage.reorderAria': '拖动以重新排序',
|
||||
'chat.queuedMessage.toast.queueFailed': '无法将消息加入队列,已放回输入框。',
|
||||
'chat.queuedMessage.toast.takeFailed': '无法加载队列中的消息,请重试。',
|
||||
'chat.container.returnToParent.aria': '返回父会话',
|
||||
'chat.container.returnToParent.titleNamed': '返回到:{title}',
|
||||
'chat.container.returnToParent.title': '返回父会话',
|
||||
|
||||
@@ -2178,6 +2178,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.queuedMessage.send': 'send',
|
||||
'chat.queuedMessage.removeAria': '從佇列移除',
|
||||
'chat.queuedMessage.reorderAria': '拖曳以重新排序',
|
||||
'chat.queuedMessage.toast.queueFailed': '無法將訊息加入佇列,已放回輸入框。',
|
||||
'chat.queuedMessage.toast.takeFailed': '無法載入佇列中的訊息,請再試一次。',
|
||||
'chat.container.returnToParent.aria': '返回父會話',
|
||||
'chat.container.returnToParent.titleNamed': '返回到:{title}',
|
||||
'chat.container.returnToParent.title': '返回父會話',
|
||||
|
||||
@@ -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` 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. 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.
|
||||
`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.
|
||||
|
||||
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.
|
||||
|
||||
`useGlobalSessionsStore.ts` owns cold/global active and archived session coverage. Its entity map and active root, parent/child, and directory indexes are maintained in the same transaction as the compatibility arrays and `sessionsByDirectory`. Full authoritative snapshots may rebuild those indexes once; direct create, update, move, archive, and delete mutations update only affected hierarchy and directory buckets. Metadata-only updates preserve the structure reference. It is complementary to directory child stores: it is not the source of live busy/retry status or session messages.
|
||||
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import type { AttachedFile } from "./types/sessionTypes"
|
||||
import type { MessageQueueUpdatedEvent } from "./messageQueueStore"
|
||||
|
||||
type FetchCall = { path: string; method: string; body: ReturnType<typeof JSON.parse> }
|
||||
let calls: FetchCall[] = []
|
||||
let respond: (call: FetchCall) => Response = () => new Response("{}", { status: 200 })
|
||||
|
||||
mock.module("@/lib/runtime-fetch", () => ({
|
||||
runtimeFetch: async (path: string, init?: RequestInit) => {
|
||||
const call = {
|
||||
path,
|
||||
method: init?.method ?? "GET",
|
||||
body: init?.body === undefined ? undefined : JSON.parse(String(init.body)),
|
||||
}
|
||||
calls.push(call)
|
||||
return respond(call)
|
||||
},
|
||||
}))
|
||||
const desktop = await import("@/lib/desktop")
|
||||
mock.module("@/lib/desktop", () => ({ ...desktop, isVSCodeRuntime: () => false }))
|
||||
mock.module("@/lib/runtime-switch", () => ({ getRuntimeKey: () => "runtime-a" }))
|
||||
mock.module("@/lib/persistence", () => ({ updateDesktopSettings: async () => undefined }))
|
||||
|
||||
const {
|
||||
applyMessageQueueUpdatedEvent,
|
||||
createMessageQueueTarget,
|
||||
getMessageQueueKey,
|
||||
useMessageQueueStore,
|
||||
} = await import("./messageQueueStore")
|
||||
|
||||
type ServerItem = MessageQueueUpdatedEvent["properties"]["session"]["items"][number]
|
||||
type ServerSession = MessageQueueUpdatedEvent["properties"]["session"]
|
||||
|
||||
type ServerReply = {
|
||||
revision: number
|
||||
session?: ServerSession
|
||||
sessions?: ServerSession[]
|
||||
item?: ServerItem
|
||||
items?: ServerItem[]
|
||||
}
|
||||
|
||||
const json = (value: ServerReply, status = 200) => new Response(JSON.stringify(value), { status })
|
||||
|
||||
const target = createMessageQueueTarget("session-1", "/repo", "runtime-a")!
|
||||
const key = getMessageQueueKey(target)
|
||||
|
||||
const serverItem = (id: string, content: string, extra: Partial<ServerItem> = {}): ServerItem => ({
|
||||
id,
|
||||
createdAt: 1,
|
||||
content,
|
||||
attachments: [],
|
||||
sendConfig: { providerID: "p", modelID: "m" },
|
||||
...extra,
|
||||
})
|
||||
|
||||
const session = (items: ServerItem[], sendingId: string | null = null): ServerSession => ({
|
||||
sessionId: "session-1",
|
||||
directory: "/repo",
|
||||
items,
|
||||
sendingId,
|
||||
})
|
||||
|
||||
const updated = (revision: number, updatedSession: ServerSession): MessageQueueUpdatedEvent => ({
|
||||
type: "openchamber:message-queue.updated",
|
||||
properties: { revision, session: updatedSession },
|
||||
})
|
||||
|
||||
const attachment: AttachedFile = {
|
||||
id: "att-1",
|
||||
file: new File(["hi"], "note.txt", { type: "text/plain" }),
|
||||
dataUrl: "data:text/plain;base64,aGk=",
|
||||
mimeType: "text/plain",
|
||||
filename: "note.txt",
|
||||
size: 2,
|
||||
source: "local",
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
calls = []
|
||||
respond = () => json({ revision: 1, session: session([]) })
|
||||
// Forgetting also drops the revision guard, so each test starts unordered.
|
||||
useMessageQueueStore.getState().forgetQueue(target)
|
||||
useMessageQueueStore.setState({ queuedMessages: {}, quarantinedLegacyMessages: {}, sendingIds: {} })
|
||||
})
|
||||
|
||||
describe("server-owned message queue", () => {
|
||||
// First: the one-time upload of a legacy local queue happens before this
|
||||
// runtime is known to be server-owned, which the later hydrations establish.
|
||||
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" } }],
|
||||
},
|
||||
})
|
||||
respond = (call) => (call.method === "POST"
|
||||
? json({ revision: 2, session: session([serverItem("q1", "from before")]) })
|
||||
: json({ revision: 2, sessions: [session([serverItem("q1", "from before")])] }))
|
||||
await useMessageQueueStore.getState().hydrate()
|
||||
|
||||
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" } } },
|
||||
})
|
||||
expect(calls[1]?.path).toBe("/api/message-queue")
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["q1"])
|
||||
})
|
||||
|
||||
test("hydrate replaces the runtime's projection with the server queue", async () => {
|
||||
respond = () => json({ revision: 3, sessions: [session([serverItem("q1", "hello")], "q1")] })
|
||||
await useMessageQueueStore.getState().hydrate()
|
||||
|
||||
expect(calls.map((call) => `${call.method} ${call.path}`)).toEqual(["GET /api/message-queue"])
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.content)).toEqual(["hello"])
|
||||
expect(useMessageQueueStore.getState().sendingIds[key]).toEqual(["q1"])
|
||||
})
|
||||
|
||||
test("addToQueue shows the message at once and settles on the server's copy", async () => {
|
||||
respond = () => json({ revision: 5, session: session([serverItem("srv-1", "hi @reviewer", { agentMention: "reviewer" })]) })
|
||||
const pending = useMessageQueueStore.getState().addToQueue(target, {
|
||||
content: "hi @reviewer",
|
||||
text: "hi",
|
||||
agentMention: "reviewer",
|
||||
attachments: [attachment],
|
||||
sendConfig: { providerID: "p", modelID: "m", agent: "build" },
|
||||
})
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]).toHaveLength(1)
|
||||
await pending
|
||||
|
||||
expect(calls[0]).toEqual({
|
||||
method: "POST",
|
||||
path: "/api/message-queue/sessions/session-1/items",
|
||||
body: {
|
||||
directory: "/repo",
|
||||
item: {
|
||||
content: "hi @reviewer",
|
||||
text: "hi",
|
||||
agentMention: "reviewer",
|
||||
attachments: [{ id: "att-1", filename: "note.txt", mimeType: "text/plain", size: 2, source: "local", dataUrl: attachment.dataUrl }],
|
||||
sendConfig: { providerID: "p", modelID: "m", agent: "build" },
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["srv-1"])
|
||||
})
|
||||
|
||||
test("addToQueue rolls the optimistic entry back when the server refuses", async () => {
|
||||
respond = () => new Response("nope", { status: 500 })
|
||||
await expect(useMessageQueueStore.getState().addToQueue(target, {
|
||||
content: "x",
|
||||
sendConfig: { providerID: "p", modelID: "m" },
|
||||
})).rejects.toThrow()
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]).toBe(undefined)
|
||||
})
|
||||
|
||||
test("addToQueue refuses a message with no captured model", async () => {
|
||||
await expect(useMessageQueueStore.getState().addToQueue(target, { content: "x" })).rejects.toThrow()
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]).toBe(undefined)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("takeForSend brings the full message back, attachments included", async () => {
|
||||
respond = () => json({
|
||||
revision: 7,
|
||||
session: session([]),
|
||||
item: serverItem("q1", "with file", {
|
||||
attachments: [{ id: "att-1", filename: "note.txt", mimeType: "text/plain", size: 2, source: "local", dataUrl: "data:text/plain;base64,aGk=" }],
|
||||
}),
|
||||
})
|
||||
const [taken] = await useMessageQueueStore.getState().takeForSend(target, "q1")
|
||||
|
||||
expect(calls[0]?.path).toBe("/api/message-queue/sessions/session-1/items/q1/take")
|
||||
expect(calls[0]?.method).toBe("POST")
|
||||
expect(taken?.content).toBe("with file")
|
||||
expect(taken?.attachments?.[0]?.dataUrl).toBe("data:text/plain;base64,aGk=")
|
||||
expect(taken?.attachments?.[0]?.file.size).toBe(2)
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]).toBe(undefined)
|
||||
})
|
||||
|
||||
test("takeForSend without an id takes everything the server is not already sending", async () => {
|
||||
respond = () => json({ revision: 8, session: session([serverItem("q1", "in flight")], "q1"), items: [serverItem("q2", "second")] })
|
||||
const taken = await useMessageQueueStore.getState().takeForSend(target)
|
||||
|
||||
expect(calls[0]?.path).toBe("/api/message-queue/sessions/session-1/take")
|
||||
expect(calls[0]?.method).toBe("POST")
|
||||
expect(taken.map((m) => m.content)).toEqual(["second"])
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["q1"])
|
||||
})
|
||||
|
||||
test("broadcasts update the projection but never move it backwards", () => {
|
||||
applyMessageQueueUpdatedEvent(updated(4, session([serverItem("q1", "newer")])), "runtime-a")
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.content)).toEqual(["newer"])
|
||||
|
||||
applyMessageQueueUpdatedEvent(updated(2, session([serverItem("q0", "older")])), "runtime-a")
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.content)).toEqual(["newer"])
|
||||
|
||||
applyMessageQueueUpdatedEvent(updated(9, session([serverItem("q1", "newer")])), "runtime-b")
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.content)).toEqual(["newer"])
|
||||
})
|
||||
|
||||
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 }] } })
|
||||
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"])
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(calls[0]).toEqual({ method: "DELETE", path: "/api/message-queue/sessions/session-1/items/q1", body: undefined })
|
||||
|
||||
respond = () => json({ revision: 11, session: session([]) })
|
||||
useMessageQueueStore.getState().clearQueue(target)
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]).toBe(undefined)
|
||||
await Promise.resolve()
|
||||
expect(calls[1]).toEqual({ method: "DELETE", path: "/api/message-queue/sessions/session-1", body: undefined })
|
||||
})
|
||||
|
||||
test("reorderQueue sends the complete new order", async () => {
|
||||
useMessageQueueStore.setState({ queuedMessages: { [key]: [{ id: "q1", content: "a", createdAt: 1 }, { id: "q2", content: "b", createdAt: 2 }] } })
|
||||
respond = () => json({ revision: 12, session: session([serverItem("q2", "b"), serverItem("q1", "a")]) })
|
||||
useMessageQueueStore.getState().reorderQueue(target, "q2", "q1")
|
||||
await Promise.resolve()
|
||||
expect(calls[0]).toEqual({ method: "PUT", path: "/api/message-queue/sessions/session-1/order", body: { itemIds: ["q2", "q1"] } })
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,17 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test"
|
||||
import {
|
||||
import { beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
|
||||
// The local queue is the VS Code behavior; every other runtime hands the
|
||||
// queue to the server (see messageQueueStore.server.test.ts).
|
||||
const desktop = await import("@/lib/desktop")
|
||||
mock.module("@/lib/desktop", () => ({ ...desktop, isVSCodeRuntime: () => true }))
|
||||
|
||||
const {
|
||||
createMessageQueueTarget,
|
||||
getMessageQueueKey,
|
||||
migrateMessageQueueState,
|
||||
parseMessageQueueKey,
|
||||
useMessageQueueStore,
|
||||
} from "./messageQueueStore"
|
||||
} = await import("./messageQueueStore")
|
||||
|
||||
beforeEach(() => {
|
||||
useMessageQueueStore.setState({ queuedMessages: {}, quarantinedLegacyMessages: {}, sendingIds: {} })
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import { z } from 'zod';
|
||||
import type { Event } from '@opencode-ai/sdk/v2';
|
||||
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
|
||||
import type { AttachedFile } from './types/sessionTypes';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { normalizePath } from '@/lib/pathNormalization';
|
||||
|
||||
export type FollowUpBehavior = 'steer' | 'queue';
|
||||
@@ -40,18 +44,38 @@ export const normalizeFollowUpBehavior = (
|
||||
return DEFAULT_FOLLOW_UP_BEHAVIOR;
|
||||
};
|
||||
|
||||
/**
|
||||
* Who delivers the queue. Web, desktop, and mobile talk to an OpenChamber
|
||||
* server that owns the queue and sends it whether or not any UI is open. VS
|
||||
* Code has no server of its own, so the extension UI keeps the local queue
|
||||
* and the foreground auto-send hook.
|
||||
*/
|
||||
export const isServerOwnedMessageQueue = (): boolean => !isVSCodeRuntime();
|
||||
|
||||
export interface QueuedMessageSendConfig {
|
||||
providerID: string;
|
||||
modelID: string;
|
||||
agent?: string;
|
||||
variant?: string;
|
||||
}
|
||||
|
||||
export interface QueuedMessage {
|
||||
id: string;
|
||||
content: string;
|
||||
attachments?: AttachedFile[];
|
||||
createdAt: number;
|
||||
/** Send config captured at queue time — used as-is when auto-sending */
|
||||
sendConfig?: {
|
||||
providerID: string;
|
||||
modelID: string;
|
||||
agent?: string;
|
||||
variant?: string;
|
||||
};
|
||||
sendConfig?: QueuedMessageSendConfig;
|
||||
}
|
||||
|
||||
interface QueuedMessageInput {
|
||||
content: string;
|
||||
attachments?: AttachedFile[];
|
||||
sendConfig?: QueuedMessageSendConfig;
|
||||
/** Text to deliver once the agent mention is stripped; defaults to `content`. */
|
||||
text?: string;
|
||||
/** Agent mentioned at the start of `content`, delivered as an agent part. */
|
||||
agentMention?: string;
|
||||
}
|
||||
|
||||
export type MessageQueueTarget = {
|
||||
@@ -81,6 +105,177 @@ export const parseMessageQueueKey = (key: string): MessageQueueTarget | null =>
|
||||
return createMessageQueueTarget(sessionParts.join('\n'), directory, runtimeKey);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server contract (packages/web/server/lib/message-queue)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const serverSendConfigSchema = z.object({
|
||||
providerID: z.string().min(1),
|
||||
modelID: z.string().min(1),
|
||||
agent: z.string().optional(),
|
||||
variant: z.string().optional(),
|
||||
});
|
||||
|
||||
const serverAttachmentSchema = z.object({
|
||||
id: z.string(),
|
||||
filename: z.string(),
|
||||
mimeType: z.string(),
|
||||
size: z.number(),
|
||||
source: z.enum(['local', 'server', 'vscode']),
|
||||
serverPath: z.string().optional(),
|
||||
/** Present only on a taken item; broadcasts and snapshots omit payloads. */
|
||||
dataUrl: z.string().optional(),
|
||||
});
|
||||
|
||||
const serverItemSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
createdAt: z.number(),
|
||||
content: z.string(),
|
||||
agentMention: z.string().optional(),
|
||||
attachments: z.array(serverAttachmentSchema),
|
||||
sendConfig: serverSendConfigSchema,
|
||||
});
|
||||
|
||||
const serverSessionSchema = z.object({
|
||||
sessionId: z.string().min(1),
|
||||
directory: z.string(),
|
||||
items: z.array(serverItemSchema),
|
||||
sendingId: z.string().nullable(),
|
||||
});
|
||||
|
||||
const serverSnapshotSchema = z.object({
|
||||
revision: z.number(),
|
||||
sessions: z.array(serverSessionSchema),
|
||||
});
|
||||
|
||||
const serverSessionResponseSchema = z.object({
|
||||
revision: z.number(),
|
||||
session: serverSessionSchema,
|
||||
});
|
||||
|
||||
const serverTakeResponseSchema = serverSessionResponseSchema.extend({ item: serverItemSchema });
|
||||
const serverTakeAllResponseSchema = serverSessionResponseSchema.extend({ items: z.array(serverItemSchema) });
|
||||
|
||||
type ServerQueueSession = z.infer<typeof serverSessionSchema>;
|
||||
type ServerQueueItem = z.infer<typeof serverItemSchema>;
|
||||
type ServerQueueAttachment = z.infer<typeof serverAttachmentSchema>;
|
||||
|
||||
const decodeDataUrl = (dataUrl: string): ArrayBuffer | null => {
|
||||
const commaIndex = dataUrl.indexOf(',');
|
||||
if (!dataUrl.startsWith('data:') || commaIndex === -1) return null;
|
||||
const meta = dataUrl.slice(5, commaIndex);
|
||||
const payload = dataUrl.slice(commaIndex + 1);
|
||||
try {
|
||||
if (meta.endsWith(';base64')) {
|
||||
const binary = atob(payload);
|
||||
const buffer = new ArrayBuffer(binary.length);
|
||||
const bytes = new Uint8Array(buffer);
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
return buffer;
|
||||
}
|
||||
const encoded = new TextEncoder().encode(decodeURIComponent(payload));
|
||||
const buffer = new ArrayBuffer(encoded.byteLength);
|
||||
new Uint8Array(buffer).set(encoded);
|
||||
return buffer;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** A taken item carries its payload; a projection item has an empty file. */
|
||||
const toAttachedFile = (attachment: ServerQueueAttachment): AttachedFile => {
|
||||
const dataUrl = attachment.dataUrl ?? '';
|
||||
const bytes = dataUrl ? decodeDataUrl(dataUrl) : null;
|
||||
const file: AttachedFile = {
|
||||
id: attachment.id,
|
||||
file: new File(bytes ? [bytes] : [], attachment.filename, { type: attachment.mimeType }),
|
||||
dataUrl,
|
||||
mimeType: attachment.mimeType,
|
||||
filename: attachment.filename,
|
||||
size: attachment.size,
|
||||
source: attachment.source,
|
||||
};
|
||||
if (attachment.serverPath) file.serverPath = attachment.serverPath;
|
||||
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 },
|
||||
});
|
||||
|
||||
type ServerQueueAttachmentInput = Omit<ServerQueueAttachment, 'dataUrl'> & { dataUrl: string };
|
||||
|
||||
type ServerQueueItemInput = {
|
||||
content: string;
|
||||
text: string;
|
||||
agentMention?: string;
|
||||
attachments: ServerQueueAttachmentInput[];
|
||||
sendConfig: QueuedMessageSendConfig;
|
||||
};
|
||||
|
||||
type ServerQueueRequestBody =
|
||||
| { directory: string; item: ServerQueueItemInput }
|
||||
| { itemIds: string[] }
|
||||
| { held: boolean };
|
||||
|
||||
const toServerAttachment = (attachment: AttachedFile): ServerQueueAttachmentInput => {
|
||||
const input: ServerQueueAttachmentInput = {
|
||||
id: attachment.id,
|
||||
filename: attachment.filename,
|
||||
mimeType: attachment.mimeType,
|
||||
size: attachment.size,
|
||||
source: attachment.source,
|
||||
dataUrl: attachment.dataUrl,
|
||||
};
|
||||
if (attachment.serverPath) input.serverPath = attachment.serverPath;
|
||||
return input;
|
||||
};
|
||||
|
||||
const toServerItemInput = (message: QueuedMessageInput, sendConfig: QueuedMessageSendConfig): ServerQueueItemInput => {
|
||||
const item: ServerQueueItemInput = {
|
||||
content: message.content,
|
||||
text: message.text ?? message.content,
|
||||
attachments: (message.attachments ?? []).filter((file) => Boolean(file.dataUrl)).map(toServerAttachment),
|
||||
sendConfig,
|
||||
};
|
||||
if (message.agentMention) item.agentMention = message.agentMention;
|
||||
return item;
|
||||
};
|
||||
|
||||
const requestJson = async <T,>(schema: z.ZodType<T>, path: string, init?: RequestInit): Promise<T> => {
|
||||
const response = await runtimeFetch(path, init);
|
||||
if (!response.ok) {
|
||||
const error: Error & { status?: number } = new Error(`Message queue request failed (${response.status})`);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
const parsed = schema.safeParse(await response.json());
|
||||
if (!parsed.success) throw new Error('Invalid message queue response');
|
||||
return parsed.data;
|
||||
};
|
||||
|
||||
const jsonInit = (method: string, body?: ServerQueueRequestBody): RequestInit => {
|
||||
if (body === undefined) return { method };
|
||||
return { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) };
|
||||
};
|
||||
|
||||
const sessionPath = (sessionId: string) => `/api/message-queue/sessions/${encodeURIComponent(sessionId)}`;
|
||||
|
||||
/**
|
||||
* Runtime keys whose queue the server owns, established by a successful
|
||||
* hydration. Their entries are a projection and must not be persisted: a
|
||||
* stale local copy would resurrect messages the server already delivered.
|
||||
*/
|
||||
const serverOwnedRuntimeKeys = new Set<string>();
|
||||
|
||||
/** Server revision last applied per queue key; older snapshots are ignored. */
|
||||
const appliedRevisions = new Map<string, number>();
|
||||
let hydrationGeneration = 0;
|
||||
|
||||
interface MessageQueueState {
|
||||
queuedMessages: Record<string, QueuedMessage[]>; // runtime + directory + session → queue
|
||||
quarantinedLegacyMessages: Record<string, QueuedMessage[]>;
|
||||
@@ -95,23 +290,39 @@ interface MessageQueueState {
|
||||
* twice. Dispatchers must skip entries listed here.
|
||||
*
|
||||
* Never persisted: a restart has no in-flight sends, and a stale flag would
|
||||
* strand a queued message permanently.
|
||||
* strand a queued message permanently. With a server-owned queue this
|
||||
* mirrors the server's in-flight item.
|
||||
*/
|
||||
sendingIds: Record<string, string[]>;
|
||||
}
|
||||
|
||||
interface MessageQueueActions {
|
||||
addToQueue: (target: MessageQueueTarget, message: Omit<QueuedMessage, 'id' | 'createdAt'>) => void;
|
||||
addToQueue: (target: MessageQueueTarget, message: QueuedMessageInput) => Promise<void>;
|
||||
removeFromQueue: (target: MessageQueueTarget, messageId: string) => void;
|
||||
reorderQueue: (target: MessageQueueTarget, fromId: string, toId: string) => void;
|
||||
popToInput: (target: MessageQueueTarget, messageId: string) => QueuedMessage | null;
|
||||
/** Removes the message and returns it in full, attachments included. */
|
||||
popToInput: (target: MessageQueueTarget, messageId: string) => Promise<QueuedMessage | null>;
|
||||
/**
|
||||
* Removes what the composer is about to send itself — one message or every
|
||||
* message not already being delivered — and returns it in full.
|
||||
*/
|
||||
takeForSend: (target: MessageQueueTarget, messageId?: string) => Promise<QueuedMessage[]>;
|
||||
clearQueue: (target: MessageQueueTarget) => void;
|
||||
/** Drops the local projection only (the session is gone); never a server call. */
|
||||
forgetQueue: (target: MessageQueueTarget) => void;
|
||||
clearAllQueues: () => void;
|
||||
markSending: (target: MessageQueueTarget, messageId: string) => void;
|
||||
clearSending: (target: MessageQueueTarget, messageId: string) => void;
|
||||
getSendableQueue: (target: MessageQueueTarget) => QueuedMessage[];
|
||||
setFollowUpBehavior: (behavior: FollowUpBehavior) => void;
|
||||
getQueueForTarget: (target: MessageQueueTarget) => QueuedMessage[];
|
||||
/** Server-owned queue: load the authoritative queue for the active runtime. */
|
||||
hydrate: () => Promise<void>;
|
||||
/** Server-owned queue: apply one session's authoritative state (broadcast or response). */
|
||||
applyServerSession: (session: ServerQueueSession, revision: number, expectedRuntimeKey: string) => void;
|
||||
/** Server-owned queue: tell the server to hold or release a session's delivery. */
|
||||
setServerHold: (sessionId: string, held: boolean) => Promise<void>;
|
||||
resetForRuntimeSwitch: (previousRuntimeKey: string | null | undefined) => void;
|
||||
}
|
||||
|
||||
type MessageQueueStore = MessageQueueState & MessageQueueActions;
|
||||
@@ -136,190 +347,362 @@ export const migrateMessageQueueState = (persistedState: unknown, version: numbe
|
||||
};
|
||||
};
|
||||
|
||||
const withoutKey = <T,>(record: Record<string, T>, key: string): Record<string, T> => {
|
||||
const { [key]: _removed, ...rest } = record;
|
||||
void _removed;
|
||||
return rest;
|
||||
};
|
||||
|
||||
const removeMessageLocally = (
|
||||
state: Pick<MessageQueueState, 'queuedMessages'>,
|
||||
key: string,
|
||||
messageId: string,
|
||||
): Pick<MessageQueueState, 'queuedMessages'> => {
|
||||
const newQueue = (state.queuedMessages[key] ?? []).filter((m) => m.id !== messageId);
|
||||
if (newQueue.length === 0) return { queuedMessages: withoutKey(state.queuedMessages, key) };
|
||||
return { queuedMessages: { ...state.queuedMessages, [key]: newQueue } };
|
||||
};
|
||||
|
||||
export const useMessageQueueStore = create<MessageQueueStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
queuedMessages: {},
|
||||
quarantinedLegacyMessages: {},
|
||||
followUpBehavior: DEFAULT_FOLLOW_UP_BEHAVIOR,
|
||||
sendingIds: {},
|
||||
|
||||
addToQueue: (target, message) => {
|
||||
(set, get) => {
|
||||
const applyServerSession = (session: ServerQueueSession, revision: number, expectedRuntimeKey: string) => {
|
||||
if (expectedRuntimeKey !== getRuntimeKey()) return;
|
||||
const target = createMessageQueueTarget(session.sessionId, session.directory, expectedRuntimeKey);
|
||||
if (!target) return;
|
||||
const key = getMessageQueueKey(target);
|
||||
const id = `queued-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
const queuedMessage: QueuedMessage = {
|
||||
id,
|
||||
content: message.content,
|
||||
attachments: message.attachments,
|
||||
createdAt: Date.now(),
|
||||
sendConfig: message.sendConfig,
|
||||
};
|
||||
|
||||
if ((appliedRevisions.get(key) ?? -1) > revision) return;
|
||||
appliedRevisions.set(key, revision);
|
||||
set((state) => {
|
||||
const currentQueue = state.queuedMessages[key] ?? [];
|
||||
const queuedMessages = {
|
||||
...state.queuedMessages,
|
||||
[key]: [...currentQueue, queuedMessage].slice(-MAX_MESSAGES_PER_QUEUE),
|
||||
};
|
||||
const keys = Object.keys(queuedMessages);
|
||||
if (keys.length > MAX_QUEUE_TARGETS) {
|
||||
keys.sort((left, right) => (
|
||||
(queuedMessages[left]?.[0]?.createdAt ?? 0) - (queuedMessages[right]?.[0]?.createdAt ?? 0)
|
||||
));
|
||||
for (const staleKey of keys.slice(0, keys.length - MAX_QUEUE_TARGETS)) delete queuedMessages[staleKey];
|
||||
}
|
||||
return {
|
||||
queuedMessages,
|
||||
};
|
||||
const queue = session.items.map(toQueuedMessage);
|
||||
const queuedMessages = queue.length > 0
|
||||
? { ...state.queuedMessages, [key]: queue }
|
||||
: withoutKey(state.queuedMessages, key);
|
||||
const sendingIds = session.sendingId
|
||||
? { ...state.sendingIds, [key]: [session.sendingId] }
|
||||
: withoutKey(state.sendingIds, key);
|
||||
return { queuedMessages, sendingIds };
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
removeFromQueue: (target, messageId) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
set((state) => {
|
||||
const currentQueue = state.queuedMessages[key] ?? [];
|
||||
const newQueue = currentQueue.filter((m) => m.id !== messageId);
|
||||
|
||||
if (newQueue.length === 0) {
|
||||
const { [key]: _removed, ...rest } = state.queuedMessages;
|
||||
void _removed;
|
||||
return { queuedMessages: rest };
|
||||
}
|
||||
|
||||
return {
|
||||
queuedMessages: {
|
||||
/** Server state wins; a failed round-trip re-reads it instead of guessing. */
|
||||
const refreshSession = async (target: MessageQueueTarget) => {
|
||||
try {
|
||||
const snapshot = await requestJson(serverSnapshotSchema, '/api/message-queue');
|
||||
const session = snapshot.sessions.find((entry) => entry.sessionId === target.sessionId)
|
||||
?? { sessionId: target.sessionId, directory: target.directory, items: [], sendingId: null };
|
||||
applyServerSession(session, snapshot.revision, target.runtimeKey);
|
||||
} catch {
|
||||
// Offline: keep the optimistic projection; the next broadcast or hydration corrects it.
|
||||
}
|
||||
};
|
||||
|
||||
const serverMutation = async (
|
||||
target: MessageQueueTarget,
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
) => {
|
||||
try {
|
||||
const result = await requestJson(serverSessionResponseSchema, path, init);
|
||||
applyServerSession(result.session, result.revision, target.runtimeKey);
|
||||
} catch (error) {
|
||||
console.warn('[queue] server update failed:', error);
|
||||
await refreshSession(target);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
queuedMessages: {},
|
||||
quarantinedLegacyMessages: {},
|
||||
followUpBehavior: DEFAULT_FOLLOW_UP_BEHAVIOR,
|
||||
sendingIds: {},
|
||||
|
||||
addToQueue: async (target, message) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
const id = `queued-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
const queuedMessage: QueuedMessage = {
|
||||
id,
|
||||
content: message.content,
|
||||
attachments: message.attachments,
|
||||
createdAt: Date.now(),
|
||||
sendConfig: message.sendConfig,
|
||||
};
|
||||
|
||||
set((state) => {
|
||||
const currentQueue = state.queuedMessages[key] ?? [];
|
||||
const queuedMessages = {
|
||||
...state.queuedMessages,
|
||||
[key]: newQueue,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
[key]: [...currentQueue, queuedMessage].slice(-MAX_MESSAGES_PER_QUEUE),
|
||||
};
|
||||
const keys = Object.keys(queuedMessages);
|
||||
if (keys.length > MAX_QUEUE_TARGETS) {
|
||||
keys.sort((left, right) => (
|
||||
(queuedMessages[left]?.[0]?.createdAt ?? 0) - (queuedMessages[right]?.[0]?.createdAt ?? 0)
|
||||
));
|
||||
for (const staleKey of keys.slice(0, keys.length - MAX_QUEUE_TARGETS)) delete queuedMessages[staleKey];
|
||||
}
|
||||
return {
|
||||
queuedMessages,
|
||||
};
|
||||
});
|
||||
|
||||
reorderQueue: (target, fromId, toId) => {
|
||||
if (fromId === toId) return;
|
||||
const key = getMessageQueueKey(target);
|
||||
set((state) => {
|
||||
const currentQueue = state.queuedMessages[key];
|
||||
if (!currentQueue) return state;
|
||||
if (!isServerOwnedMessageQueue()) return;
|
||||
if (!message.sendConfig) {
|
||||
set((state) => removeMessageLocally(state, key, id));
|
||||
throw new Error('A queued message needs a provider and model to be delivered later.');
|
||||
}
|
||||
try {
|
||||
const result = await requestJson(serverSessionResponseSchema, `${sessionPath(target.sessionId)}/items`, jsonInit('POST', {
|
||||
directory: target.directory,
|
||||
item: toServerItemInput(message, message.sendConfig),
|
||||
}));
|
||||
// The optimistic entry is replaced by the server's copy of the queue.
|
||||
set((state) => removeMessageLocally(state, key, id));
|
||||
applyServerSession(result.session, result.revision, target.runtimeKey);
|
||||
} catch (error) {
|
||||
set((state) => removeMessageLocally(state, key, id));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
removeFromQueue: (target, messageId) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
set((state) => removeMessageLocally(state, key, messageId));
|
||||
if (isServerOwnedMessageQueue()) {
|
||||
void serverMutation(target, `${sessionPath(target.sessionId)}/items/${encodeURIComponent(messageId)}`, jsonInit('DELETE'));
|
||||
}
|
||||
},
|
||||
|
||||
reorderQueue: (target, fromId, toId) => {
|
||||
if (fromId === toId) return;
|
||||
const key = getMessageQueueKey(target);
|
||||
const currentQueue = get().queuedMessages[key];
|
||||
if (!currentQueue) return;
|
||||
const fromIndex = currentQueue.findIndex((m) => m.id === fromId);
|
||||
const toIndex = currentQueue.findIndex((m) => m.id === toId);
|
||||
if (fromIndex === -1 || toIndex === -1) return state;
|
||||
if (fromIndex === -1 || toIndex === -1) return;
|
||||
|
||||
const newQueue = currentQueue.slice();
|
||||
const [moved] = newQueue.splice(fromIndex, 1);
|
||||
newQueue.splice(toIndex, 0, moved);
|
||||
|
||||
return {
|
||||
set((state) => ({
|
||||
queuedMessages: {
|
||||
...state.queuedMessages,
|
||||
[key]: newQueue,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
popToInput: (target, messageId) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
const state = get();
|
||||
const currentQueue = state.queuedMessages[key] ?? [];
|
||||
const message = currentQueue.find((m) => m.id === messageId);
|
||||
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove from queue
|
||||
set((prevState) => {
|
||||
const queue = prevState.queuedMessages[key] ?? [];
|
||||
const newQueue = queue.filter((m) => m.id !== messageId);
|
||||
|
||||
if (newQueue.length === 0) {
|
||||
const { [key]: _removed, ...rest } = prevState.queuedMessages;
|
||||
void _removed;
|
||||
return { queuedMessages: rest };
|
||||
}));
|
||||
if (isServerOwnedMessageQueue()) {
|
||||
const itemIds = newQueue.map((message) => message.id);
|
||||
void serverMutation(target, `${sessionPath(target.sessionId)}/order`, jsonInit('PUT', { itemIds }));
|
||||
}
|
||||
|
||||
return {
|
||||
queuedMessages: {
|
||||
...prevState.queuedMessages,
|
||||
[key]: newQueue,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
return message;
|
||||
},
|
||||
popToInput: async (target, messageId) => {
|
||||
const [message] = await get().takeForSend(target, messageId);
|
||||
return message ?? null;
|
||||
},
|
||||
|
||||
clearQueue: (target) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
set((state) => {
|
||||
// Clearing drops what is still queued, never a message
|
||||
// already handed to the server: that send will resolve
|
||||
// and must find its entry to remove or restore.
|
||||
takeForSend: async (target, messageId) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
if (isServerOwnedMessageQueue()) {
|
||||
if (messageId) {
|
||||
const result = await requestJson(
|
||||
serverTakeResponseSchema,
|
||||
`${sessionPath(target.sessionId)}/items/${encodeURIComponent(messageId)}/take`,
|
||||
jsonInit('POST'),
|
||||
);
|
||||
applyServerSession(result.session, result.revision, target.runtimeKey);
|
||||
return [toQueuedMessage(result.item)];
|
||||
}
|
||||
const result = await requestJson(serverTakeAllResponseSchema, `${sessionPath(target.sessionId)}/take`, jsonInit('POST'));
|
||||
applyServerSession(result.session, result.revision, target.runtimeKey);
|
||||
return result.items.map(toQueuedMessage);
|
||||
}
|
||||
|
||||
const state = get();
|
||||
const sending = state.sendingIds[key] ?? [];
|
||||
const retained = (state.queuedMessages[key] ?? []).filter((m) => sending.includes(m.id));
|
||||
if (retained.length > 0) {
|
||||
return { queuedMessages: { ...state.queuedMessages, [key]: retained } };
|
||||
const taken = (state.queuedMessages[key] ?? []).filter((message) => (
|
||||
(messageId ? message.id === messageId : true) && !sending.includes(message.id)
|
||||
));
|
||||
if (taken.length === 0) return [];
|
||||
const takenIds = new Set(taken.map((message) => message.id));
|
||||
set((prevState) => {
|
||||
const remaining = (prevState.queuedMessages[key] ?? []).filter((message) => !takenIds.has(message.id));
|
||||
if (remaining.length === 0) return { queuedMessages: withoutKey(prevState.queuedMessages, key) };
|
||||
return { queuedMessages: { ...prevState.queuedMessages, [key]: remaining } };
|
||||
});
|
||||
return taken;
|
||||
},
|
||||
|
||||
clearQueue: (target) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
set((state) => {
|
||||
// Clearing drops what is still queued, never a message
|
||||
// already handed to the server: that send will resolve
|
||||
// and must find its entry to remove or restore.
|
||||
const sending = state.sendingIds[key] ?? [];
|
||||
const retained = (state.queuedMessages[key] ?? []).filter((m) => sending.includes(m.id));
|
||||
if (retained.length > 0) {
|
||||
return { queuedMessages: { ...state.queuedMessages, [key]: retained } };
|
||||
}
|
||||
return { queuedMessages: withoutKey(state.queuedMessages, key) };
|
||||
});
|
||||
if (isServerOwnedMessageQueue()) {
|
||||
void serverMutation(target, sessionPath(target.sessionId), jsonInit('DELETE'));
|
||||
}
|
||||
const { [key]: _removed, ...rest } = state.queuedMessages;
|
||||
void _removed;
|
||||
return { queuedMessages: rest };
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
clearAllQueues: () => {
|
||||
set({ queuedMessages: {}, sendingIds: {} });
|
||||
},
|
||||
forgetQueue: (target) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
appliedRevisions.delete(key);
|
||||
set((state) => ({
|
||||
queuedMessages: withoutKey(state.queuedMessages, key),
|
||||
sendingIds: withoutKey(state.sendingIds, key),
|
||||
}));
|
||||
},
|
||||
|
||||
markSending: (target, messageId) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
set((state) => {
|
||||
const current = state.sendingIds[key] ?? [];
|
||||
if (current.includes(messageId)) return state;
|
||||
return { sendingIds: { ...state.sendingIds, [key]: [...current, messageId] } };
|
||||
});
|
||||
},
|
||||
clearAllQueues: () => {
|
||||
set({ queuedMessages: {}, sendingIds: {} });
|
||||
},
|
||||
|
||||
clearSending: (target, messageId) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
set((state) => {
|
||||
const current = state.sendingIds[key];
|
||||
if (!current || !current.includes(messageId)) return state;
|
||||
const next = current.filter((id) => id !== messageId);
|
||||
if (next.length === 0) {
|
||||
const { [key]: _removed, ...rest } = state.sendingIds;
|
||||
void _removed;
|
||||
return { sendingIds: rest };
|
||||
markSending: (target, messageId) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
set((state) => {
|
||||
const current = state.sendingIds[key] ?? [];
|
||||
if (current.includes(messageId)) return state;
|
||||
return { sendingIds: { ...state.sendingIds, [key]: [...current, messageId] } };
|
||||
});
|
||||
},
|
||||
|
||||
clearSending: (target, messageId) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
set((state) => {
|
||||
const current = state.sendingIds[key];
|
||||
if (!current || !current.includes(messageId)) return state;
|
||||
const next = current.filter((id) => id !== messageId);
|
||||
if (next.length === 0) return { sendingIds: withoutKey(state.sendingIds, key) };
|
||||
return { sendingIds: { ...state.sendingIds, [key]: next } };
|
||||
});
|
||||
},
|
||||
|
||||
getSendableQueue: (target) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
const state = get();
|
||||
const queue = state.queuedMessages[key] ?? [];
|
||||
const sending = state.sendingIds[key];
|
||||
if (!sending || sending.length === 0) return queue;
|
||||
return queue.filter((message) => !sending.includes(message.id));
|
||||
},
|
||||
|
||||
setFollowUpBehavior: (behavior) => {
|
||||
set({ followUpBehavior: behavior });
|
||||
void updateDesktopSettings({ followUpBehavior: behavior });
|
||||
},
|
||||
|
||||
getQueueForTarget: (target) => {
|
||||
return get().queuedMessages[getMessageQueueKey(target)] ?? [];
|
||||
},
|
||||
|
||||
hydrate: async () => {
|
||||
if (!isServerOwnedMessageQueue()) return;
|
||||
const runtimeKey = getRuntimeKey();
|
||||
const generation = ++hydrationGeneration;
|
||||
const isCurrent = () => generation === hydrationGeneration && runtimeKey === getRuntimeKey();
|
||||
|
||||
// Messages queued by an older build live in this browser only.
|
||||
// Hand them to the server once so they are still delivered;
|
||||
// whatever cannot be uploaded is superseded by the server's queue.
|
||||
const legacyEntries = Object.entries(get().queuedMessages)
|
||||
.map(([key, queue]) => ({ target: parseMessageQueueKey(key), queue }))
|
||||
.filter((entry): entry is { target: MessageQueueTarget; queue: QueuedMessage[] } => (
|
||||
entry.target !== null && entry.target.runtimeKey === runtimeKey && !serverOwnedRuntimeKeys.has(runtimeKey)
|
||||
));
|
||||
for (const { target, queue } of legacyEntries) {
|
||||
for (const message of queue) {
|
||||
if (!message.sendConfig) continue;
|
||||
try {
|
||||
await requestJson(serverSessionResponseSchema, `${sessionPath(target.sessionId)}/items`, jsonInit('POST', {
|
||||
directory: target.directory,
|
||||
item: toServerItemInput(message, message.sendConfig),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.warn('[queue] failed to migrate a locally queued message to the server:', error);
|
||||
}
|
||||
if (!isCurrent()) return;
|
||||
}
|
||||
}
|
||||
return { sendingIds: { ...state.sendingIds, [key]: next } };
|
||||
});
|
||||
},
|
||||
|
||||
getSendableQueue: (target) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
const state = get();
|
||||
const queue = state.queuedMessages[key] ?? [];
|
||||
const sending = state.sendingIds[key];
|
||||
if (!sending || sending.length === 0) return queue;
|
||||
return queue.filter((message) => !sending.includes(message.id));
|
||||
},
|
||||
const snapshot = await requestJson(serverSnapshotSchema, '/api/message-queue');
|
||||
if (!isCurrent()) return;
|
||||
serverOwnedRuntimeKeys.add(runtimeKey);
|
||||
set((state) => {
|
||||
const queuedMessages: Record<string, QueuedMessage[]> = {};
|
||||
const sendingIds: Record<string, string[]> = {};
|
||||
for (const [key, queue] of Object.entries(state.queuedMessages)) {
|
||||
if (parseMessageQueueKey(key)?.runtimeKey !== runtimeKey) queuedMessages[key] = queue;
|
||||
}
|
||||
for (const [key, ids] of Object.entries(state.sendingIds)) {
|
||||
if (parseMessageQueueKey(key)?.runtimeKey !== runtimeKey) sendingIds[key] = ids;
|
||||
}
|
||||
for (const session of snapshot.sessions) {
|
||||
const target = createMessageQueueTarget(session.sessionId, session.directory, runtimeKey);
|
||||
if (!target) continue;
|
||||
const key = getMessageQueueKey(target);
|
||||
if ((appliedRevisions.get(key) ?? -1) > snapshot.revision) {
|
||||
// A broadcast newer than this snapshot already landed; keep it.
|
||||
if (state.queuedMessages[key]) queuedMessages[key] = state.queuedMessages[key];
|
||||
if (state.sendingIds[key]) sendingIds[key] = state.sendingIds[key];
|
||||
continue;
|
||||
}
|
||||
appliedRevisions.set(key, snapshot.revision);
|
||||
if (session.items.length > 0) queuedMessages[key] = session.items.map(toQueuedMessage);
|
||||
if (session.sendingId) sendingIds[key] = [session.sendingId];
|
||||
}
|
||||
return { queuedMessages, sendingIds };
|
||||
});
|
||||
},
|
||||
|
||||
setFollowUpBehavior: (behavior) => {
|
||||
set({ followUpBehavior: behavior });
|
||||
void updateDesktopSettings({ followUpBehavior: behavior });
|
||||
},
|
||||
applyServerSession,
|
||||
|
||||
getQueueForTarget: (target) => {
|
||||
return get().queuedMessages[getMessageQueueKey(target)] ?? [];
|
||||
},
|
||||
}),
|
||||
setServerHold: async (sessionId, held) => {
|
||||
if (!isServerOwnedMessageQueue()) return;
|
||||
const response = await runtimeFetch(`${sessionPath(sessionId)}/hold`, jsonInit('PUT', { held }));
|
||||
if (!response.ok) throw new Error(`Message queue hold request failed (${response.status})`);
|
||||
},
|
||||
|
||||
resetForRuntimeSwitch: (previousRuntimeKey) => {
|
||||
hydrationGeneration += 1;
|
||||
if (!previousRuntimeKey || !serverOwnedRuntimeKeys.has(previousRuntimeKey)) return;
|
||||
// The previous runtime's projection belongs to its server;
|
||||
// switching back re-hydrates it from there.
|
||||
set((state) => {
|
||||
const queuedMessages: Record<string, QueuedMessage[]> = {};
|
||||
const sendingIds: Record<string, string[]> = {};
|
||||
for (const [key, queue] of Object.entries(state.queuedMessages)) {
|
||||
if (parseMessageQueueKey(key)?.runtimeKey === previousRuntimeKey) appliedRevisions.delete(key);
|
||||
else queuedMessages[key] = queue;
|
||||
}
|
||||
for (const [key, ids] of Object.entries(state.sendingIds)) {
|
||||
if (parseMessageQueueKey(key)?.runtimeKey !== previousRuntimeKey) sendingIds[key] = ids;
|
||||
}
|
||||
return { queuedMessages, sendingIds };
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
{
|
||||
name: 'message-queue-store',
|
||||
version: 2,
|
||||
storage: createDeferredSafeJSONStorage(),
|
||||
partialize: (state) => ({
|
||||
queuedMessages: state.queuedMessages,
|
||||
queuedMessages: Object.fromEntries(
|
||||
Object.entries(state.queuedMessages).filter(([key]) => {
|
||||
const runtimeKey = parseMessageQueueKey(key)?.runtimeKey;
|
||||
return !runtimeKey || !serverOwnedRuntimeKeys.has(runtimeKey);
|
||||
}),
|
||||
),
|
||||
quarantinedLegacyMessages: state.quarantinedLegacyMessages,
|
||||
followUpBehavior: state.followUpBehavior,
|
||||
}),
|
||||
@@ -331,3 +714,21 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const serverUpdatedEventSchema = z.object({
|
||||
properties: z.object({ revision: z.number(), session: serverSessionSchema }),
|
||||
});
|
||||
|
||||
export type MessageQueueUpdatedEvent = {
|
||||
type: 'openchamber:message-queue.updated';
|
||||
properties: z.infer<typeof serverUpdatedEventSchema>['properties'];
|
||||
};
|
||||
|
||||
/** `openchamber:message-queue.updated` broadcast → projection. */
|
||||
export const applyMessageQueueUpdatedEvent = (payload: Event | MessageQueueUpdatedEvent, expectedRuntimeKey: string): void => {
|
||||
if (!isServerOwnedMessageQueue()) return;
|
||||
const parsed = serverUpdatedEventSchema.safeParse(payload);
|
||||
if (!parsed.success) return;
|
||||
const { session, revision } = parsed.data.properties;
|
||||
useMessageQueueStore.getState().applyServerSession(session, revision, expectedRuntimeKey);
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
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`.
|
||||
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.
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Todo } from '@opencode-ai/sdk/v2/client';
|
||||
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { createChatDraftIdentity, readChatDraft, writeChatDraft } from '@/lib/chatDraftPersistence';
|
||||
import { createMessageQueueTarget, useMessageQueueStore } from '@/stores/messageQueueStore';
|
||||
import { createMessageQueueTarget, getMessageQueueKey, useMessageQueueStore } from '@/stores/messageQueueStore';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import { useTodosPersistStore } from '@/stores/useTodosPersistStore';
|
||||
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
|
||||
@@ -25,8 +25,14 @@ describe('cleanupPersistedSessionState', () => {
|
||||
const runtimeKey = getRuntimeKey();
|
||||
const deleted = createMessageQueueTarget('session-1', '/repo-a', runtimeKey)!;
|
||||
const retained = createMessageQueueTarget('session-1', '/repo-b', runtimeKey)!;
|
||||
useMessageQueueStore.getState().addToQueue(deleted, { content: 'delete' });
|
||||
useMessageQueueStore.getState().addToQueue(retained, { content: 'retain' });
|
||||
// Outside VS Code the queue is a projection of the server's; seed it the
|
||||
// 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 }],
|
||||
},
|
||||
});
|
||||
useTodosPersistStore.getState().setSessionTodos('/repo-a', 'session-1', [todo]);
|
||||
useTodosPersistStore.getState().setSessionTodos('/repo-b', 'session-1', [todo]);
|
||||
const deletedDraft = createChatDraftIdentity(runtimeKey, '/repo-a', 'session-1')!;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { clearChatDraft, createChatDraftIdentity } from '@/lib/chatDraftPersistence';
|
||||
import { createMessageQueueTarget, useMessageQueueStore } from '@/stores/messageQueueStore';
|
||||
import { createMessageQueueTarget, isServerOwnedMessageQueue, useMessageQueueStore } from '@/stores/messageQueueStore';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import { useTodosPersistStore } from '@/stores/useTodosPersistStore';
|
||||
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
|
||||
@@ -14,7 +14,12 @@ export const cleanupPersistedSessionState = (identity: {
|
||||
if (identity.runtimeKey !== getRuntimeKey() || !identity.directory || identity.directory === 'global' || !identity.sessionId) return;
|
||||
|
||||
const queueTarget = createMessageQueueTarget(identity.sessionId, identity.directory, identity.runtimeKey);
|
||||
if (queueTarget) useMessageQueueStore.getState().clearQueue(queueTarget);
|
||||
if (queueTarget) {
|
||||
// A server-owned queue drops the deleted session itself; only the local
|
||||
// projection needs to go. VS Code owns its queue and clears it here.
|
||||
if (isServerOwnedMessageQueue()) useMessageQueueStore.getState().forgetQueue(queueTarget);
|
||||
else useMessageQueueStore.getState().clearQueue(queueTarget);
|
||||
}
|
||||
useTodosPersistStore.getState().clearSessionTodos(identity.runtimeKey, identity.directory, identity.sessionId);
|
||||
useSessionFoldersStore.getState().removeSessionEverywhere(identity.runtimeKey, identity.sessionId);
|
||||
useInlineCommentDraftStore.getState().clearSessionDrafts(identity.runtimeKey, identity.directory, identity.sessionId);
|
||||
|
||||
@@ -48,6 +48,7 @@ import { getReconnectCandidateSessionIds, mergeBootstrapSessions } from "./recon
|
||||
import { messagesBefore } from "./message-ordering"
|
||||
import { opencodeClient } from "@/lib/opencode/client"
|
||||
import { usePermissionStore } from "@/stores/permissionStore"
|
||||
import { applyMessageQueueUpdatedEvent, useMessageQueueStore } from "@/stores/messageQueueStore"
|
||||
import {
|
||||
processVSCodePermissionAutoAccept,
|
||||
processVSCodeReconciledPermissionAutoAccept,
|
||||
@@ -1577,6 +1578,11 @@ export function handleEvent(
|
||||
batch?: DirectoryEventBatch,
|
||||
globalEffectsAlreadyApplied = false,
|
||||
) {
|
||||
if ((payload as { type?: unknown }).type === "openchamber:message-queue.updated") {
|
||||
applyMessageQueueUpdatedEvent(payload, expectedRuntimeKey)
|
||||
return
|
||||
}
|
||||
|
||||
if ((payload as { type?: unknown }).type === "openchamber:permission-auto-accept.updated") {
|
||||
const properties = (payload as unknown as { properties?: unknown }).properties
|
||||
if (properties && typeof properties === "object") {
|
||||
@@ -2241,6 +2247,7 @@ export function SyncProvider(props: {
|
||||
// Configure child store manager
|
||||
useEffect(() => {
|
||||
void usePermissionStore.getState().hydrate().catch(() => undefined)
|
||||
void useMessageQueueStore.getState().hydrate().catch(() => undefined)
|
||||
}, [props.sdk])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -91,6 +91,7 @@ import { createPushRuntime } from './lib/notifications/push-runtime.js';
|
||||
import { createApnsRuntime } from './lib/notifications/apns-runtime.js';
|
||||
import { createNotificationTemplateRuntime } from './lib/notifications/template-runtime.js';
|
||||
import { createPermissionAutoAcceptRuntime } from './lib/permission-auto-accept/runtime.js';
|
||||
import { createMessageQueueRuntime } from './lib/message-queue/runtime.js';
|
||||
import { createGracefulShutdownRuntime } from './lib/opencode/shutdown-runtime.js';
|
||||
import { createProjectConfigRuntime } from './lib/projects/project-config.js';
|
||||
import { createProjectContextRuntime } from './lib/project-context/runtime.js';
|
||||
@@ -878,6 +879,19 @@ notificationTriggerRuntime.setGetIsSessionAutoAccepting(
|
||||
(sessionId, directory) => permissionAutoAcceptRuntime.isSessionAutoAccepting(sessionId, directory),
|
||||
);
|
||||
|
||||
// Queued follow-up messages are delivered by the server so a closed tab or a
|
||||
// dropped connection no longer strands them (VS Code keeps its UI-side queue).
|
||||
const messageQueueRuntime = createMessageQueueRuntime({
|
||||
globalEventHub: globalMessageStreamHub,
|
||||
buildOpenCodeUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
sessionKnowledgeRuntime,
|
||||
broadcastGlobalUiEvent,
|
||||
onPromptSent: (sessionId) => sessionRuntime.markUserMessageSent(sessionId),
|
||||
dataDir: OPENCHAMBER_DATA_DIR,
|
||||
});
|
||||
messageQueueRuntime.start();
|
||||
|
||||
const openCodeWatcherRuntime = createOpenCodeWatcherRuntime({
|
||||
waitForOpenCodePort: (...args) => waitForOpenCodePort(...args),
|
||||
buildOpenCodeUrl,
|
||||
@@ -1427,6 +1441,7 @@ const gracefulShutdownRuntime = createGracefulShutdownRuntime({
|
||||
sessionAssistRuntime,
|
||||
sessionGoalRuntime,
|
||||
contextObligatoryRuntime,
|
||||
messageQueueRuntime,
|
||||
sessionRuntime,
|
||||
getHealthCheckInterval: () => healthCheckInterval,
|
||||
clearHealthCheckInterval: (value) => clearInterval(value),
|
||||
@@ -1914,6 +1929,7 @@ async function main(options = {}) {
|
||||
getOpenChamberEventClients: () => uiOpenChamberEventClients,
|
||||
writeSseEvent,
|
||||
permissionAutoAcceptRuntime,
|
||||
messageQueueRuntime,
|
||||
});
|
||||
|
||||
const startupPipelineResult = await startupPipelineRuntime.run({
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# Message Queue
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns the messages a user queued while a session was busy, and sends them the
|
||||
moment the session goes idle. The queue lives in the web server so a closed
|
||||
tab, a locked phone, or a dropped connection no longer strands it. Structural
|
||||
template: `permission-auto-accept` — the server is authoritative, the shared UI
|
||||
renders a projection, and VS Code (which has no server of its own) keeps its
|
||||
UI-side queue and foreground auto-send hook.
|
||||
|
||||
## Files
|
||||
|
||||
- `runtime.js` — `createMessageQueueRuntime(...)` (state, persistence,
|
||||
dispatch loop, event handling) and `registerMessageQueueRoutes(app, runtime)`.
|
||||
- `runtime.test.js` — delivery, idleness gates, retries, holds, persistence,
|
||||
concurrency with in-flight sends, slash commands, and project knowledge.
|
||||
|
||||
Wiring: created in `server/index.js` after the global event hub and the
|
||||
session-knowledge runtime; routes registered in
|
||||
`opencode/feature-routes-runtime.js` (before the generic OpenCode proxy) with
|
||||
JSON bodies enabled in `opencode/core-routes.js`; stopped by
|
||||
`opencode/shutdown-runtime.js`.
|
||||
|
||||
## Item
|
||||
|
||||
An item is what the UI would have sent itself, captured at queue time so the
|
||||
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
|
||||
agentMention?, // delivered as an `agent` part
|
||||
attachments: [{ id, filename, mimeType, size, source, serverPath?, dataUrl }],
|
||||
sendConfig: { providerID, modelID, agent?, variant? } // required
|
||||
}
|
||||
```
|
||||
|
||||
`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`.
|
||||
|
||||
## Persistence
|
||||
|
||||
`<data-dir>/message-queue.json` (`OPENCHAMBER_DATA_DIR` or
|
||||
`~/.config/openchamber`): `{ version, revision, sessions: { [sessionId]:
|
||||
{ directory, items } } }`, written atomically (temp file + rename) through a
|
||||
serialized write chain. A missing file is an empty queue. A malformed file is
|
||||
a failure, not an empty queue: it is moved aside as
|
||||
`message-queue.json.corrupt-<timestamp>` before the runtime starts empty, so
|
||||
the next write cannot overwrite the user's data. A failed read leaves writes
|
||||
disabled until a later load succeeds. `revision` is a global monotonic counter
|
||||
bumped on every mutation; clients use it to reject stale snapshots.
|
||||
|
||||
In-memory only, deliberately: the in-flight item (`sendingId`), retry
|
||||
backoff, abort timestamps, and holds. A restart has no in-flight sends; a
|
||||
persisted "sending" flag would strand a message forever.
|
||||
|
||||
## Delivery loop
|
||||
|
||||
1. `start()` subscribes to the global upstream hub and loads the file; on
|
||||
load and on every hub `connect` it arms every session that has items.
|
||||
2. `session.status` for a queued session: `idle` arms a short quiet timer
|
||||
(500 ms, coalescing the burst around a turn boundary), `busy`/`retry`
|
||||
clears it. A `message.updated` for a completed assistant reply arms as
|
||||
well, so a missed idle event cannot strand the queue. `session.deleted`
|
||||
drops the session's queue. An assistant `MessageAbortedError` records an
|
||||
abort.
|
||||
3. `tick(sessionId)` bails when the queue is empty, an item is in flight, or
|
||||
the session is held. It re-arms after a 2 s post-abort hold (the UI's
|
||||
old behavior: a stop is not immediately followed by the next prompt) or
|
||||
while the head item is in retry backoff.
|
||||
4. Idleness is re-verified against OpenCode before sending, because
|
||||
`prompt_async` into a running turn steers into it instead of starting the
|
||||
next one: `GET /session/status` must not list the session as busy/retry,
|
||||
and the trailing message must not be an unfinished assistant reply (the
|
||||
status map only lists busy sessions, so a missed busy event leaves no
|
||||
entry while a turn still streams). A failed fetch is unknown, never idle:
|
||||
the tick re-arms with backoff.
|
||||
5. The head is marked in flight (broadcast), then sent:
|
||||
- text starting with `/` that names a command in OpenCode's `/command`
|
||||
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.
|
||||
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.
|
||||
6. The next item goes out after the next busy → idle cycle.
|
||||
|
||||
## Holds
|
||||
|
||||
Auto-review is driven from the UI and bounces the original session through
|
||||
idle between iterations; the UI tells the server to hold that session's queue
|
||||
(`PUT .../hold { held: true, ttlMs? }`) while a run is going and releases it
|
||||
when the run ends. A hold expires on its own (default 5 min, cap 10 min)
|
||||
because the UI that asserted it may be gone; the UI re-asserts it every two
|
||||
minutes while the run continues. Releasing arms a dispatch.
|
||||
|
||||
## Routes (`/api/message-queue`)
|
||||
|
||||
Normal authenticated OpenChamber runtime routes; never on browser URL-token
|
||||
allowlists.
|
||||
|
||||
| Route | Purpose |
|
||||
|---|---|
|
||||
| `GET /api/message-queue` | Full snapshot `{ revision, sessions[] }` |
|
||||
| `POST .../sessions/:id/items` | Append `{ directory, item }`; returns `{ revision, session, itemId }` and arms a dispatch (the session may already be idle) |
|
||||
| `DELETE .../sessions/:id/items/:itemId` | Remove; `409` while that item is in flight |
|
||||
| `POST .../sessions/:id/items/:itemId/take` | Remove and return the full item (payloads included); `404`/`409` |
|
||||
| `POST .../sessions/:id/take` | Remove and return every item not in flight, in order |
|
||||
| `PUT .../sessions/:id/order` | `{ itemIds }` must be a complete permutation |
|
||||
| `DELETE .../sessions/:id` | Clear; the in-flight item stays |
|
||||
| `PUT .../sessions/:id/hold` | `{ held, ttlMs? }` |
|
||||
|
||||
Every mutation broadcasts `openchamber:message-queue.updated` with
|
||||
`{ revision, session }` to all connected clients (SSE and WS), so several
|
||||
devices on one server see one queue.
|
||||
|
||||
Limits: 20 items per session, 50 sessions (oldest evicted, never one with an
|
||||
item in flight), 200k characters of content; attachment payloads are bounded
|
||||
by the route family's 50 MB JSON limit.
|
||||
|
||||
## UI ownership
|
||||
|
||||
`packages/ui/src/stores/messageQueueStore.ts` is the projection: see its
|
||||
section in `packages/ui/src/stores/DOCUMENTATION.md`. VS Code intentionally
|
||||
does not use this module; with all OpenChamber webviews closed, queued
|
||||
messages there are not delivered.
|
||||
@@ -0,0 +1,778 @@
|
||||
// Server-owned message queue: messages the user queued while a session was
|
||||
// busy, delivered by the web server the moment the session goes idle. The
|
||||
// queue lives here, not in the browser, so closing the tab, locking the phone,
|
||||
// or losing the connection no longer strands what was queued. Structural
|
||||
// template: permission-auto-accept (server-authoritative state, UI as a
|
||||
// projection, VS Code keeps its own foreground implementation).
|
||||
//
|
||||
// Event-driven like session-goal: the shared upstream hub delivers
|
||||
// `session.status`, and an idle transition arms a short per-session timer. The
|
||||
// tick re-verifies idleness against OpenCode (status map + message tail) before
|
||||
// it sends, because a queued prompt sent into a running turn would be steered
|
||||
// into it instead of starting the next one.
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const QUEUE_FILE_NAME = 'message-queue.json';
|
||||
const QUEUE_FILE_VERSION = 1;
|
||||
|
||||
const MAX_SESSIONS = 50;
|
||||
const MAX_ITEMS_PER_SESSION = 20;
|
||||
const CONTENT_CHAR_LIMIT = 200_000;
|
||||
|
||||
// Idle events arrive in bursts around a turn boundary; a short quiet window
|
||||
// coalesces them before the tick verifies idleness against OpenCode.
|
||||
const DISPATCH_QUIET_MS = 500;
|
||||
// After a user abort the UI held the queue for two seconds so the stop is not
|
||||
// immediately followed by the next prompt; the server keeps that window.
|
||||
const ABORT_HOLD_MS = 2_000;
|
||||
const RETRY_BASE_DELAY_MS = 2_000;
|
||||
const RETRY_MAX_DELAY_MS = 60_000;
|
||||
// A hold is asserted by a UI-driven process (auto-review) that dies with the
|
||||
// UI; it expires unless the UI keeps re-asserting it.
|
||||
const HOLD_DEFAULT_TTL_MS = 5 * 60 * 1000;
|
||||
const HOLD_MAX_TTL_MS = 10 * 60 * 1000;
|
||||
const FETCH_TIMEOUT_MS = 15_000;
|
||||
const MESSAGE_TAIL_LIMIT = 2;
|
||||
|
||||
const ATTACHMENT_SOURCES = new Set(['local', 'server', 'vscode']);
|
||||
const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{4,128}$/;
|
||||
|
||||
const getQueuedSendRetryDelayMs = (failures) =>
|
||||
Math.min(RETRY_BASE_DELAY_MS * 2 ** Math.max(failures - 1, 0), RETRY_MAX_DELAY_MS);
|
||||
|
||||
// Boundary readers: the only place raw JSON (client bodies, the queue file,
|
||||
// OpenCode responses, hub events) is inspected. Everything below them
|
||||
// branches on the domain values they return.
|
||||
const asNonEmptyString = (value) => (typeof value === 'string' && value.trim() ? value.trim() : '');
|
||||
const asText = (value) => (typeof value === 'string' ? value : '');
|
||||
const asRecord = (value) => (value && typeof value === 'object' && !Array.isArray(value) ? value : null);
|
||||
const asList = (value) => (Array.isArray(value) ? value : null);
|
||||
const asCount = (value) => (Number.isFinite(value) && value >= 0 ? Math.floor(value) : null);
|
||||
|
||||
const isValidSessionId = (value) => SESSION_ID_PATTERN.test(asNonEmptyString(value));
|
||||
|
||||
const httpError = (message, status) => Object.assign(new Error(message), { status });
|
||||
|
||||
const parseSendConfig = (value) => {
|
||||
const raw = asRecord(value);
|
||||
if (!raw) return null;
|
||||
const providerID = asNonEmptyString(raw.providerID);
|
||||
const modelID = asNonEmptyString(raw.modelID);
|
||||
if (!providerID || !modelID) return null;
|
||||
const sendConfig = { providerID, modelID };
|
||||
const agent = asNonEmptyString(raw.agent);
|
||||
if (agent) sendConfig.agent = agent;
|
||||
const variant = asNonEmptyString(raw.variant);
|
||||
if (variant) sendConfig.variant = variant;
|
||||
return sendConfig;
|
||||
};
|
||||
|
||||
const parseAttachment = (value) => {
|
||||
const raw = asRecord(value);
|
||||
if (!raw) return null;
|
||||
const filename = asNonEmptyString(raw.filename);
|
||||
const mimeType = asNonEmptyString(raw.mimeType);
|
||||
const dataUrl = asText(raw.dataUrl);
|
||||
if (!filename || !mimeType || !dataUrl) return null;
|
||||
const attachment = {
|
||||
id: asNonEmptyString(raw.id) || `attachment-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
filename,
|
||||
mimeType,
|
||||
size: asCount(raw.size) ?? 0,
|
||||
source: ATTACHMENT_SOURCES.has(raw.source) ? raw.source : 'local',
|
||||
};
|
||||
const serverPath = asNonEmptyString(raw.serverPath);
|
||||
if (serverPath) attachment.serverPath = serverPath;
|
||||
attachment.dataUrl = dataUrl;
|
||||
return attachment;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
* the server cannot send.
|
||||
*/
|
||||
export const parseQueuedItemInput = (value) => {
|
||||
const raw = asRecord(value);
|
||||
if (!raw) throw new TypeError('item is required');
|
||||
const content = asText(raw.content).replace(/^\n+|\n+$/g, '');
|
||||
if (content.length > CONTENT_CHAR_LIMIT) throw new TypeError('item content is too long');
|
||||
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 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.sendConfig = sendConfig;
|
||||
return item;
|
||||
};
|
||||
|
||||
const parseStoredItem = (value) => {
|
||||
const raw = asRecord(value);
|
||||
const id = raw ? asNonEmptyString(raw.id) : '';
|
||||
if (!id) return null;
|
||||
try {
|
||||
return { id, createdAt: asCount(raw.createdAt) ?? Date.now(), ...parseQueuedItemInput(raw) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
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.
|
||||
const toPublicItem = (item) => {
|
||||
const publicItem = { id: item.id, createdAt: item.createdAt, content: item.content };
|
||||
if (item.agentMention) publicItem.agentMention = item.agentMention;
|
||||
publicItem.attachments = item.attachments.map(toPublicAttachment);
|
||||
publicItem.sendConfig = { ...item.sendConfig };
|
||||
return publicItem;
|
||||
};
|
||||
|
||||
const extractSessionStatus = (payload) => {
|
||||
if (payload.type !== 'session.status') return null;
|
||||
const properties = asRecord(payload.properties) ?? {};
|
||||
const status = asRecord(properties.status) ?? {};
|
||||
const info = asRecord(properties.info) ?? {};
|
||||
const sessionId = asNonEmptyString(properties.sessionID);
|
||||
const type = asNonEmptyString(status.type) || asNonEmptyString(info.type);
|
||||
if (!sessionId || !type) return null;
|
||||
return { sessionId, type };
|
||||
};
|
||||
|
||||
const extractAssistantMessageUpdate = (payload) => {
|
||||
if (payload.type !== 'message.updated') return null;
|
||||
const info = asRecord(asRecord(payload.properties)?.info);
|
||||
if (!info || info.role !== 'assistant') return null;
|
||||
const sessionId = asNonEmptyString(info.sessionID);
|
||||
if (!sessionId) return null;
|
||||
return {
|
||||
sessionId,
|
||||
aborted: asRecord(info.error)?.name === 'MessageAbortedError',
|
||||
completed: asCount(asRecord(info.time)?.completed) !== null,
|
||||
};
|
||||
};
|
||||
|
||||
const extractDeletedSessionId = (payload) => {
|
||||
if (payload.type !== 'session.deleted') return null;
|
||||
const properties = asRecord(payload.properties) ?? {};
|
||||
return asNonEmptyString(asRecord(properties.info)?.id) || asNonEmptyString(properties.sessionID) || null;
|
||||
};
|
||||
|
||||
export function createMessageQueueRuntime({
|
||||
globalEventHub,
|
||||
buildOpenCodeUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
sessionKnowledgeRuntime = null,
|
||||
broadcastGlobalUiEvent,
|
||||
onPromptSent,
|
||||
dataDir,
|
||||
fetchImpl = fetch,
|
||||
now = Date.now,
|
||||
dispatchQuietMs = DISPATCH_QUIET_MS,
|
||||
abortHoldMs = ABORT_HOLD_MS,
|
||||
retryDelayMs = getQueuedSendRetryDelayMs,
|
||||
}) {
|
||||
const filePath = path.join(dataDir, QUEUE_FILE_NAME);
|
||||
|
||||
/** sessionId → { directory, items } */
|
||||
const queues = new Map();
|
||||
let revision = 0;
|
||||
let loadPromise = null;
|
||||
let writePromise = Promise.resolve();
|
||||
let stopped = false;
|
||||
|
||||
/** In-memory only — a restart has no in-flight sends. */
|
||||
const sending = new Map(); // sessionId → itemId
|
||||
const timers = new Map(); // sessionId → timeout
|
||||
const failures = new Map(); // sessionId → { itemId, failures, nextAttemptAt }
|
||||
const abortedAt = new Map(); // sessionId → timestamp
|
||||
const holds = new Map(); // sessionId → expiresAt
|
||||
|
||||
// --- persistence ---------------------------------------------------------
|
||||
|
||||
const serialize = () => ({
|
||||
version: QUEUE_FILE_VERSION,
|
||||
revision,
|
||||
sessions: Object.fromEntries(
|
||||
Array.from(queues.entries()).map(([sessionId, queue]) => [sessionId, { directory: queue.directory, items: queue.items }]),
|
||||
),
|
||||
});
|
||||
|
||||
const readFile = async () => {
|
||||
let raw;
|
||||
try {
|
||||
raw = await fs.promises.readFile(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
if (asRecord(error)?.code === 'ENOENT') return { sessions: {}, revision: 0 };
|
||||
throw error;
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
// Malformed is a failure, not an empty queue: keep the bytes for the
|
||||
// user and start over rather than overwriting them on the next write.
|
||||
const backup = `${filePath}.corrupt-${now()}`;
|
||||
await fs.promises.rename(filePath, backup).catch(() => undefined);
|
||||
console.warn(`[message-queue] queue file was unreadable and moved to ${backup}: ${error?.message ?? error}`);
|
||||
return { sessions: {}, revision: 0 };
|
||||
}
|
||||
const stored = asRecord(parsed) ?? {};
|
||||
const sessions = {};
|
||||
for (const [sessionId, value] of Object.entries(asRecord(stored.sessions) ?? {})) {
|
||||
const entry = asRecord(value);
|
||||
if (!entry || !isValidSessionId(sessionId)) continue;
|
||||
const directory = asNonEmptyString(entry.directory);
|
||||
const items = (asList(entry.items) ?? []).map(parseStoredItem).filter(Boolean);
|
||||
if (!directory || items.length === 0) continue;
|
||||
sessions[sessionId] = { directory, items };
|
||||
}
|
||||
return { sessions, revision: asCount(stored.revision) ?? 0 };
|
||||
};
|
||||
|
||||
const load = () => {
|
||||
if (!loadPromise) {
|
||||
loadPromise = readFile()
|
||||
.then((stored) => {
|
||||
for (const [sessionId, entry] of Object.entries(stored.sessions)) queues.set(sessionId, entry);
|
||||
revision = Math.max(revision, stored.revision);
|
||||
})
|
||||
.catch((error) => {
|
||||
// A read failure keeps the in-memory (empty) queue but must not be
|
||||
// mistaken for "nothing queued": the next write would clobber the
|
||||
// file, so writes stay disabled until a later load succeeds.
|
||||
loadPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return loadPromise;
|
||||
};
|
||||
|
||||
const persist = () => {
|
||||
const payload = JSON.stringify(serialize());
|
||||
writePromise = writePromise
|
||||
.then(async () => {
|
||||
await fs.promises.mkdir(dataDir, { recursive: true });
|
||||
const tmpPath = `${filePath}.${process.pid}.tmp`;
|
||||
await fs.promises.writeFile(tmpPath, payload, 'utf8');
|
||||
await fs.promises.rename(tmpPath, filePath);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[message-queue] failed to persist queue:', error?.message ?? error);
|
||||
});
|
||||
return writePromise;
|
||||
};
|
||||
|
||||
// --- snapshots -----------------------------------------------------------
|
||||
|
||||
const sessionSnapshot = (sessionId) => {
|
||||
const queue = queues.get(sessionId);
|
||||
return {
|
||||
sessionId,
|
||||
directory: queue?.directory ?? '',
|
||||
items: (queue?.items ?? []).map(toPublicItem),
|
||||
sendingId: sending.get(sessionId) ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
const snapshot = () => ({
|
||||
revision,
|
||||
sessions: Array.from(queues.keys()).map(sessionSnapshot),
|
||||
});
|
||||
|
||||
const broadcast = (sessionId) => {
|
||||
broadcastGlobalUiEvent?.({
|
||||
type: 'openchamber:message-queue.updated',
|
||||
properties: { revision, session: sessionSnapshot(sessionId) },
|
||||
});
|
||||
};
|
||||
|
||||
/** Every mutation goes through here: bump, persist, broadcast. */
|
||||
const commit = (sessionId) => {
|
||||
revision += 1;
|
||||
void persist();
|
||||
broadcast(sessionId);
|
||||
return { revision, session: sessionSnapshot(sessionId) };
|
||||
};
|
||||
|
||||
const setQueueItems = (sessionId, directory, items) => {
|
||||
if (items.length === 0) {
|
||||
queues.delete(sessionId);
|
||||
return;
|
||||
}
|
||||
queues.set(sessionId, { directory, items });
|
||||
};
|
||||
|
||||
// --- OpenCode access -----------------------------------------------------
|
||||
|
||||
const openCodeFetch = async (fetchPath, { directory, method = 'GET', body, query } = {}) => {
|
||||
const base = buildOpenCodeUrl(fetchPath, '');
|
||||
const params = new URLSearchParams(query || {});
|
||||
if (directory) params.set('directory', directory);
|
||||
const search = params.toString();
|
||||
const headers = { Accept: 'application/json', ...getOpenCodeAuthHeaders() };
|
||||
const init = { method, headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) };
|
||||
if (body) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
const response = await fetchImpl(search ? `${base}?${search}` : base, init);
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '');
|
||||
throw httpError(`OpenCode ${method} ${fetchPath} failed with ${response.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`, response.status);
|
||||
}
|
||||
return response.json().catch(() => null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Live idleness, or null when it could not be established. Unknown is never
|
||||
* idle: a fetch failure re-arms instead of sending into a running turn.
|
||||
*/
|
||||
const isSessionIdle = async (sessionId, directory) => {
|
||||
const statuses = asRecord(await openCodeFetch('/session/status', { directory }).catch(() => null));
|
||||
if (!statuses) return null;
|
||||
const type = asRecord(statuses[sessionId])?.type;
|
||||
if (type === 'busy' || type === 'retry') return false;
|
||||
// The status map lists only busy sessions, so a missed busy event leaves
|
||||
// no entry while a turn still streams. The trailing unfinished assistant
|
||||
// message is the live evidence of that turn (mirrors the UI gate).
|
||||
const messages = asList(await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/message`, {
|
||||
directory,
|
||||
query: { limit: String(MESSAGE_TAIL_LIMIT) },
|
||||
}).catch(() => null));
|
||||
if (!messages) return null;
|
||||
const last = asRecord(asRecord(messages[messages.length - 1])?.info);
|
||||
if (last?.role === 'assistant' && asCount(asRecord(last.time)?.completed) === null) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const resolveSlashCommand = async (text, directory) => {
|
||||
if (!text.startsWith('/')) return null;
|
||||
const [head, ...tail] = text.split(' ');
|
||||
const name = head.slice(1);
|
||||
if (!name) return null;
|
||||
const commands = asList(await openCodeFetch('/command', { directory })) ?? [];
|
||||
if (!commands.some((command) => asRecord(command)?.name === name)) return null;
|
||||
return { name, arguments: tail.join(' ') };
|
||||
};
|
||||
|
||||
const toFilePart = (attachment) => ({
|
||||
type: 'file',
|
||||
mime: attachment.mimeType,
|
||||
filename: attachment.filename,
|
||||
url: attachment.dataUrl,
|
||||
});
|
||||
|
||||
const sendItem = async (sessionId, directory, item) => {
|
||||
const { providerID, modelID, agent, variant } = item.sendConfig;
|
||||
const fileParts = item.attachments.map(toFilePart);
|
||||
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;
|
||||
await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/command`, { directory, method: 'POST', body });
|
||||
return;
|
||||
}
|
||||
|
||||
// Standing project context rides the prompt exactly as a UI send would
|
||||
// attach it; a failed lookup sends without it rather than not at all.
|
||||
const knowledge = sessionKnowledgeRuntime
|
||||
? 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.
|
||||
const parts = [];
|
||||
if (item.text.trim()) parts.push({ type: 'text', text: item.text });
|
||||
parts.push(...fileParts);
|
||||
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 } };
|
||||
if (agent) body.agent = agent;
|
||||
if (variant) body.variant = variant;
|
||||
body.parts = parts;
|
||||
await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/prompt_async`, { directory, method: 'POST', body });
|
||||
if (knowledge.text && sessionKnowledgeRuntime) {
|
||||
// After the prompt is accepted, so a rejected dispatch carries it again.
|
||||
await sessionKnowledgeRuntime.recordDelivered(sessionId, directory, knowledge.signature).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
// --- dispatch loop -------------------------------------------------------
|
||||
|
||||
const clearTimer = (sessionId) => {
|
||||
const existing = timers.get(sessionId);
|
||||
if (existing) {
|
||||
clearTimeout(existing);
|
||||
timers.delete(sessionId);
|
||||
}
|
||||
};
|
||||
|
||||
const armDispatch = (sessionId, delayMs = dispatchQuietMs) => {
|
||||
if (stopped || !queues.has(sessionId)) return;
|
||||
clearTimer(sessionId);
|
||||
const timer = setTimeout(() => {
|
||||
timers.delete(sessionId);
|
||||
tick(sessionId).catch((error) => {
|
||||
console.warn('[message-queue] dispatch tick failed:', error?.message ?? error);
|
||||
});
|
||||
}, Math.max(0, delayMs));
|
||||
timer.unref?.();
|
||||
timers.set(sessionId, timer);
|
||||
};
|
||||
|
||||
const isHeld = (sessionId) => {
|
||||
const expiresAt = holds.get(sessionId);
|
||||
if (expiresAt === undefined) return false;
|
||||
if (expiresAt > now()) return true;
|
||||
holds.delete(sessionId);
|
||||
return false;
|
||||
};
|
||||
|
||||
async function tick(sessionId) {
|
||||
if (stopped) return;
|
||||
const queue = queues.get(sessionId);
|
||||
if (!queue || queue.items.length === 0 || sending.has(sessionId) || isHeld(sessionId)) return;
|
||||
|
||||
const abortHoldUntil = (abortedAt.get(sessionId) ?? 0) + abortHoldMs;
|
||||
if (abortHoldUntil > now()) {
|
||||
armDispatch(sessionId, abortHoldUntil - now());
|
||||
return;
|
||||
}
|
||||
|
||||
const head = queue.items[0];
|
||||
const failure = failures.get(sessionId);
|
||||
if (failure && failure.itemId !== head.id) failures.delete(sessionId);
|
||||
else if (failure && failure.nextAttemptAt > now()) {
|
||||
armDispatch(sessionId, failure.nextAttemptAt - now());
|
||||
return;
|
||||
}
|
||||
|
||||
const idle = await isSessionIdle(sessionId, queue.directory);
|
||||
if (idle === null) {
|
||||
armDispatch(sessionId, retryDelayMs(1));
|
||||
return;
|
||||
}
|
||||
// Busy: the next idle status event re-arms the loop.
|
||||
if (!idle) return;
|
||||
|
||||
// Re-read after the awaits — the user may have edited the queue meanwhile.
|
||||
const current = queues.get(sessionId);
|
||||
const item = current?.items[0];
|
||||
if (!item || item.id !== head.id || sending.has(sessionId)) return;
|
||||
|
||||
sending.set(sessionId, item.id);
|
||||
broadcast(sessionId);
|
||||
try {
|
||||
await sendItem(sessionId, current.directory, item);
|
||||
const after = queues.get(sessionId);
|
||||
if (after) setQueueItems(sessionId, after.directory, after.items.filter((entry) => entry.id !== item.id));
|
||||
failures.delete(sessionId);
|
||||
sending.delete(sessionId);
|
||||
commit(sessionId);
|
||||
try {
|
||||
onPromptSent?.(sessionId);
|
||||
} catch {
|
||||
// bookkeeping only
|
||||
}
|
||||
console.log(`[message-queue] sent queued message to ${sessionId}`);
|
||||
} catch (error) {
|
||||
sending.delete(sessionId);
|
||||
const count = (failure?.itemId === item.id ? failure.failures : 0) + 1;
|
||||
const nextAttemptAt = now() + retryDelayMs(count);
|
||||
failures.set(sessionId, { itemId: item.id, failures: count, nextAttemptAt });
|
||||
console.warn(`[message-queue] send to ${sessionId} failed (attempt ${count}):`, error?.message ?? error);
|
||||
broadcast(sessionId);
|
||||
armDispatch(sessionId, nextAttemptAt - now());
|
||||
}
|
||||
}
|
||||
|
||||
const reconcileAll = () => {
|
||||
for (const sessionId of queues.keys()) {
|
||||
if (!timers.has(sessionId)) armDispatch(sessionId, dispatchQuietMs);
|
||||
}
|
||||
};
|
||||
|
||||
// --- public mutations ----------------------------------------------------
|
||||
|
||||
const requireSessionId = (sessionId) => {
|
||||
if (!isValidSessionId(sessionId)) throw new TypeError('sessionId is invalid');
|
||||
return sessionId;
|
||||
};
|
||||
|
||||
const enqueue = async (sessionIdInput, directoryInput, itemInput) => {
|
||||
const sessionId = requireSessionId(sessionIdInput);
|
||||
const directory = asNonEmptyString(directoryInput);
|
||||
if (!directory) throw new TypeError('directory is required');
|
||||
const parsed = parseQueuedItemInput(itemInput);
|
||||
await load();
|
||||
const item = {
|
||||
id: `queued-${now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
createdAt: now(),
|
||||
...parsed,
|
||||
};
|
||||
const existing = queues.get(sessionId);
|
||||
const items = [...(existing?.items ?? []), item].slice(-MAX_ITEMS_PER_SESSION);
|
||||
queues.set(sessionId, { directory, items });
|
||||
if (queues.size > MAX_SESSIONS) {
|
||||
const oldest = Array.from(queues.entries())
|
||||
.filter(([id]) => id !== sessionId && !sending.has(id))
|
||||
.sort((left, right) => (left[1].items[0]?.createdAt ?? 0) - (right[1].items[0]?.createdAt ?? 0))
|
||||
.slice(0, queues.size - MAX_SESSIONS);
|
||||
for (const [staleId] of oldest) {
|
||||
queues.delete(staleId);
|
||||
clearTimer(staleId);
|
||||
broadcast(staleId);
|
||||
}
|
||||
}
|
||||
const result = commit(sessionId);
|
||||
// The session may already be idle (queued from a busy-looking composer
|
||||
// right as the turn ended); the tick verifies before sending.
|
||||
armDispatch(sessionId);
|
||||
return { ...result, itemId: item.id };
|
||||
};
|
||||
|
||||
const remove = async (sessionIdInput, itemId) => {
|
||||
const sessionId = requireSessionId(sessionIdInput);
|
||||
await load();
|
||||
if (sending.get(sessionId) === itemId) throw httpError('message is being sent', 409);
|
||||
const queue = queues.get(sessionId);
|
||||
if (!queue || !queue.items.some((item) => item.id === itemId)) {
|
||||
return { revision, session: sessionSnapshot(sessionId) };
|
||||
}
|
||||
setQueueItems(sessionId, queue.directory, queue.items.filter((item) => item.id !== itemId));
|
||||
return commit(sessionId);
|
||||
};
|
||||
|
||||
/** Removes the item and hands its full payload (attachments included) back. */
|
||||
const take = async (sessionIdInput, itemId) => {
|
||||
const sessionId = requireSessionId(sessionIdInput);
|
||||
await load();
|
||||
if (sending.get(sessionId) === itemId) throw httpError('message is being sent', 409);
|
||||
const queue = queues.get(sessionId);
|
||||
const item = queue?.items.find((entry) => entry.id === itemId);
|
||||
if (!queue || !item) throw httpError('queued message not found', 404);
|
||||
setQueueItems(sessionId, queue.directory, queue.items.filter((entry) => entry.id !== itemId));
|
||||
return { ...commit(sessionId), item };
|
||||
};
|
||||
|
||||
/** Removes every item not currently being sent and hands them back in order. */
|
||||
const takeAll = async (sessionIdInput) => {
|
||||
const sessionId = requireSessionId(sessionIdInput);
|
||||
await load();
|
||||
const queue = queues.get(sessionId);
|
||||
if (!queue) return { revision, session: sessionSnapshot(sessionId), items: [] };
|
||||
const sendingId = sending.get(sessionId) ?? null;
|
||||
const items = queue.items.filter((item) => item.id !== sendingId);
|
||||
if (items.length === 0) return { revision, session: sessionSnapshot(sessionId), items: [] };
|
||||
setQueueItems(sessionId, queue.directory, queue.items.filter((item) => item.id === sendingId));
|
||||
return { ...commit(sessionId), items };
|
||||
};
|
||||
|
||||
const reorder = async (sessionIdInput, itemIds) => {
|
||||
const sessionId = requireSessionId(sessionIdInput);
|
||||
if (!asList(itemIds) || itemIds.some((id) => !asNonEmptyString(id))) {
|
||||
throw new TypeError('itemIds must be a list of ids');
|
||||
}
|
||||
await load();
|
||||
const queue = queues.get(sessionId);
|
||||
if (!queue) return { revision, session: sessionSnapshot(sessionId) };
|
||||
const byId = new Map(queue.items.map((item) => [item.id, item]));
|
||||
if (itemIds.length !== byId.size || new Set(itemIds).size !== itemIds.length || itemIds.some((id) => !byId.has(id))) {
|
||||
throw new TypeError('itemIds must list every queued message exactly once');
|
||||
}
|
||||
queues.set(sessionId, { directory: queue.directory, items: itemIds.map((id) => byId.get(id)) });
|
||||
return commit(sessionId);
|
||||
};
|
||||
|
||||
const clear = async (sessionIdInput) => {
|
||||
const sessionId = requireSessionId(sessionIdInput);
|
||||
await load();
|
||||
const queue = queues.get(sessionId);
|
||||
if (!queue) return { revision, session: sessionSnapshot(sessionId) };
|
||||
// Never drop a message already handed to OpenCode: its send resolves and
|
||||
// must find its entry.
|
||||
const sendingId = sending.get(sessionId) ?? null;
|
||||
setQueueItems(sessionId, queue.directory, queue.items.filter((item) => item.id === sendingId));
|
||||
clearTimer(sessionId);
|
||||
return commit(sessionId);
|
||||
};
|
||||
|
||||
const setHold = (sessionIdInput, held, ttlMs = HOLD_DEFAULT_TTL_MS) => {
|
||||
const sessionId = requireSessionId(sessionIdInput);
|
||||
if (held !== true && held !== false) throw new TypeError('held must be a boolean');
|
||||
if (held) {
|
||||
const ttl = Math.min(asCount(ttlMs) || HOLD_DEFAULT_TTL_MS, HOLD_MAX_TTL_MS);
|
||||
holds.set(sessionId, now() + ttl);
|
||||
clearTimer(sessionId);
|
||||
return { held: true, expiresAt: holds.get(sessionId) };
|
||||
}
|
||||
holds.delete(sessionId);
|
||||
armDispatch(sessionId);
|
||||
return { held: false, expiresAt: null };
|
||||
};
|
||||
|
||||
// --- events --------------------------------------------------------------
|
||||
|
||||
const processPayload = (value) => {
|
||||
const payload = asRecord(value);
|
||||
if (stopped || !payload) return;
|
||||
|
||||
const deletedSessionId = extractDeletedSessionId(payload);
|
||||
if (deletedSessionId) {
|
||||
if (!queues.has(deletedSessionId)) return;
|
||||
queues.delete(deletedSessionId);
|
||||
clearTimer(deletedSessionId);
|
||||
failures.delete(deletedSessionId);
|
||||
commit(deletedSessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
const status = extractSessionStatus(payload);
|
||||
if (status) {
|
||||
if (!queues.has(status.sessionId)) return;
|
||||
if (status.type === 'idle') armDispatch(status.sessionId);
|
||||
else clearTimer(status.sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
const assistant = extractAssistantMessageUpdate(payload);
|
||||
if (assistant && queues.has(assistant.sessionId)) {
|
||||
if (assistant.aborted) abortedAt.set(assistant.sessionId, now());
|
||||
// A completed reply without a following idle status (missed event)
|
||||
// must still drain the queue; the tick verifies idleness itself.
|
||||
if (assistant.completed && !timers.has(assistant.sessionId)) armDispatch(assistant.sessionId);
|
||||
}
|
||||
};
|
||||
|
||||
const processEvent = (event) => {
|
||||
const raw = asRecord(asRecord(event)?.payload);
|
||||
processPayload(asRecord(raw?.payload) ?? raw);
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
const unsubscribeEvent = globalEventHub.subscribeEvent(processEvent);
|
||||
const unsubscribeStatus = globalEventHub.subscribeStatus((status) => {
|
||||
if (status?.type === 'connect') reconcileAll();
|
||||
});
|
||||
void load()
|
||||
.then(() => {
|
||||
if (queues.size > 0) console.log(`[message-queue] restored queues for ${queues.size} session(s)`);
|
||||
reconcileAll();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[message-queue] failed to load queue file:', error?.message ?? error);
|
||||
});
|
||||
return () => {
|
||||
unsubscribeEvent();
|
||||
unsubscribeStatus();
|
||||
};
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
stopped = true;
|
||||
for (const timer of timers.values()) clearTimeout(timer);
|
||||
timers.clear();
|
||||
};
|
||||
|
||||
return {
|
||||
load,
|
||||
snapshot,
|
||||
sessionSnapshot,
|
||||
enqueue,
|
||||
remove,
|
||||
take,
|
||||
takeAll,
|
||||
reorder,
|
||||
clear,
|
||||
setHold,
|
||||
processPayload,
|
||||
start,
|
||||
stop,
|
||||
/** Drains the pending write; tests and shutdown use it. */
|
||||
flush: () => writePromise,
|
||||
};
|
||||
}
|
||||
|
||||
export function registerMessageQueueRoutes(app, runtime) {
|
||||
const respondError = (res, error, fallback) => {
|
||||
const status = error instanceof TypeError ? 400 : (Number.isInteger(error?.status) ? error.status : 500);
|
||||
res.status(status).json({ error: error?.message ?? fallback });
|
||||
};
|
||||
|
||||
app.get('/api/message-queue', async (_req, res) => {
|
||||
try {
|
||||
await runtime.load();
|
||||
res.json(runtime.snapshot());
|
||||
} catch (error) {
|
||||
respondError(res, error, 'Failed to load message queue');
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/message-queue/sessions/:sessionId/items', async (req, res) => {
|
||||
try {
|
||||
res.json(await runtime.enqueue(req.params.sessionId, req.body?.directory, req.body?.item));
|
||||
} catch (error) {
|
||||
respondError(res, error, 'Failed to queue message');
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/message-queue/sessions/:sessionId/take', async (req, res) => {
|
||||
try {
|
||||
res.json(await runtime.takeAll(req.params.sessionId));
|
||||
} catch (error) {
|
||||
respondError(res, error, 'Failed to take queued messages');
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/message-queue/sessions/:sessionId/order', async (req, res) => {
|
||||
try {
|
||||
res.json(await runtime.reorder(req.params.sessionId, req.body?.itemIds));
|
||||
} catch (error) {
|
||||
respondError(res, error, 'Failed to reorder queue');
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/message-queue/sessions/:sessionId/hold', async (req, res) => {
|
||||
try {
|
||||
await runtime.load();
|
||||
res.json(runtime.setHold(req.params.sessionId, req.body?.held, req.body?.ttlMs));
|
||||
} catch (error) {
|
||||
respondError(res, error, 'Failed to update queue hold');
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/message-queue/sessions/:sessionId', async (req, res) => {
|
||||
try {
|
||||
res.json(await runtime.clear(req.params.sessionId));
|
||||
} catch (error) {
|
||||
respondError(res, error, 'Failed to clear queue');
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/message-queue/sessions/:sessionId/items/:itemId/take', async (req, res) => {
|
||||
try {
|
||||
res.json(await runtime.take(req.params.sessionId, req.params.itemId));
|
||||
} catch (error) {
|
||||
respondError(res, error, 'Failed to take queued message');
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/message-queue/sessions/:sessionId/items/:itemId', async (req, res) => {
|
||||
try {
|
||||
res.json(await runtime.remove(req.params.sessionId, req.params.itemId));
|
||||
} catch (error) {
|
||||
respondError(res, error, 'Failed to remove queued message');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createMessageQueueRuntime, parseQueuedItemInput } from './runtime.js';
|
||||
|
||||
const SESSION = 'ses_queue_test_1';
|
||||
const DIRECTORY = '/repo';
|
||||
|
||||
const item = (overrides = {}) => ({
|
||||
content: 'follow up',
|
||||
text: 'follow up',
|
||||
attachments: [],
|
||||
sendConfig: { providerID: 'anthropic', modelID: 'claude', agent: 'build' },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const tempDirs = [];
|
||||
const makeDataDir = () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-message-queue-'));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* A fake OpenCode: status map, message tail, command list, and a log of every
|
||||
* prompt/command it received.
|
||||
*/
|
||||
const createOpenCode = () => {
|
||||
const state = {
|
||||
statuses: {},
|
||||
tail: [],
|
||||
commands: [],
|
||||
sent: [],
|
||||
failNext: null,
|
||||
};
|
||||
const fetchImpl = vi.fn(async (url, init = {}) => {
|
||||
const { pathname } = new URL(url);
|
||||
const method = init.method ?? 'GET';
|
||||
if (state.failNext && state.failNext.test(pathname)) {
|
||||
state.failNext = null;
|
||||
return new Response('boom', { status: 500 });
|
||||
}
|
||||
if (pathname === '/session/status') return Response.json(state.statuses);
|
||||
if (pathname.endsWith('/message')) return Response.json(state.tail);
|
||||
if (pathname === '/command') return Response.json(state.commands);
|
||||
if (method === 'POST' && (pathname.endsWith('/prompt_async') || pathname.endsWith('/command'))) {
|
||||
state.sent.push({ path: pathname, body: JSON.parse(init.body) });
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
return new Response('not found', { status: 404 });
|
||||
});
|
||||
return { state, fetchImpl };
|
||||
};
|
||||
|
||||
const createRuntime = ({ dataDir = makeDataDir(), openCode = createOpenCode(), knowledge = null, retryDelayMs } = {}) => {
|
||||
let eventHandler = () => {};
|
||||
let statusHandler = () => {};
|
||||
const broadcasts = [];
|
||||
const promptSent = [];
|
||||
const options = {
|
||||
globalEventHub: {
|
||||
subscribeEvent(handler) { eventHandler = handler; return () => {}; },
|
||||
subscribeStatus(handler) { statusHandler = handler; return () => {}; },
|
||||
},
|
||||
buildOpenCodeUrl: (fetchPath) => `http://opencode.test${fetchPath}`,
|
||||
getOpenCodeAuthHeaders: () => ({}),
|
||||
sessionKnowledgeRuntime: knowledge,
|
||||
broadcastGlobalUiEvent: (event) => broadcasts.push(event),
|
||||
onPromptSent: (sessionId) => promptSent.push(sessionId),
|
||||
dataDir,
|
||||
fetchImpl: openCode.fetchImpl,
|
||||
dispatchQuietMs: 0,
|
||||
abortHoldMs: 50,
|
||||
};
|
||||
if (retryDelayMs) options.retryDelayMs = retryDelayMs;
|
||||
const runtime = createMessageQueueRuntime(options);
|
||||
return {
|
||||
runtime,
|
||||
openCode,
|
||||
dataDir,
|
||||
broadcasts,
|
||||
promptSent,
|
||||
emit: (payload, directory = DIRECTORY) => eventHandler({ payload, directory }),
|
||||
connect: () => statusHandler({ type: 'connect' }),
|
||||
};
|
||||
};
|
||||
|
||||
const settle = async (ms = 30) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
};
|
||||
|
||||
describe('parseQueuedItemInput', () => {
|
||||
it('rejects an item the server could not deliver later', () => {
|
||||
expect(() => parseQueuedItemInput({ content: 'x' })).toThrow(TypeError);
|
||||
expect(() => parseQueuedItemInput(item({ content: '', text: '' }))).toThrow(TypeError);
|
||||
expect(() => parseQueuedItemInput(item({ attachments: [{ filename: 'a.png' }] }))).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('keeps delivery fields and trims blank edges of the content', () => {
|
||||
const parsed = parseQueuedItemInput(item({ content: '\n\nhello\n', text: 'hello', agentMention: 'reviewer' }));
|
||||
expect(parsed).toEqual({
|
||||
content: 'hello',
|
||||
text: 'hello',
|
||||
agentMention: 'reviewer',
|
||||
attachments: [],
|
||||
sendConfig: { providerID: 'anthropic', modelID: 'claude', agent: 'build' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('message queue runtime', () => {
|
||||
it('delivers the head of the queue when the session goes idle, in order', async () => {
|
||||
const { runtime, openCode, emit, promptSent, broadcasts } = createRuntime();
|
||||
runtime.start();
|
||||
openCode.state.statuses = { [SESSION]: { type: 'busy' } };
|
||||
|
||||
await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'first', text: 'first' }));
|
||||
await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'second', text: 'second' }));
|
||||
await settle();
|
||||
expect(openCode.state.sent).toHaveLength(0);
|
||||
|
||||
openCode.state.statuses = {};
|
||||
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
||||
await settle();
|
||||
|
||||
expect(openCode.state.sent).toHaveLength(1);
|
||||
expect(openCode.state.sent[0].path).toBe(`/session/${SESSION}/prompt_async`);
|
||||
expect(openCode.state.sent[0].body).toEqual({
|
||||
model: { providerID: 'anthropic', modelID: 'claude' },
|
||||
agent: 'build',
|
||||
parts: [{ type: 'text', text: 'first' }],
|
||||
});
|
||||
expect(promptSent).toEqual([SESSION]);
|
||||
expect(runtime.sessionSnapshot(SESSION).items.map((entry) => entry.content)).toEqual(['second']);
|
||||
// Clients learned about the in-flight item and then the removal.
|
||||
expect(broadcasts.at(-1)).toMatchObject({
|
||||
type: 'openchamber:message-queue.updated',
|
||||
properties: { session: { sessionId: SESSION, sendingId: null } },
|
||||
});
|
||||
|
||||
// The next turn: busy, then idle again — the second message goes out.
|
||||
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'busy' } } });
|
||||
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
||||
await settle();
|
||||
expect(openCode.state.sent).toHaveLength(2);
|
||||
expect(runtime.sessionSnapshot(SESSION).items).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not send into a running turn even when the status event says idle', async () => {
|
||||
const { runtime, openCode, emit } = createRuntime();
|
||||
runtime.start();
|
||||
openCode.state.tail = [{ info: { role: 'assistant', time: { created: 1 } } }];
|
||||
await runtime.enqueue(SESSION, DIRECTORY, item());
|
||||
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
||||
await settle();
|
||||
expect(openCode.state.sent).toHaveLength(0);
|
||||
|
||||
// The reply completes: that alone drains the queue (a missed idle event
|
||||
// must not strand it).
|
||||
openCode.state.tail = [{ info: { role: 'assistant', time: { created: 1, completed: 2 } } }];
|
||||
emit({ type: 'message.updated', properties: { info: { role: 'assistant', sessionID: SESSION, time: { created: 1, completed: 2 } } } });
|
||||
await settle();
|
||||
expect(openCode.state.sent).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('treats an unreachable OpenCode as unknown, not idle', async () => {
|
||||
const { runtime, openCode, emit } = createRuntime({ retryDelayMs: () => 10 });
|
||||
runtime.start();
|
||||
await runtime.enqueue(SESSION, DIRECTORY, item());
|
||||
openCode.state.failNext = /\/session\/status$/;
|
||||
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
||||
await settle(5);
|
||||
expect(openCode.state.sent).toHaveLength(0);
|
||||
// Retried after the status fetch recovers.
|
||||
await settle(40);
|
||||
expect(openCode.state.sent).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps a failed item and retries with backoff', async () => {
|
||||
const { runtime, openCode, emit, broadcasts } = createRuntime({ retryDelayMs: () => 20 });
|
||||
runtime.start();
|
||||
await runtime.enqueue(SESSION, DIRECTORY, item());
|
||||
openCode.state.failNext = /prompt_async$/;
|
||||
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
||||
await settle(10);
|
||||
expect(openCode.state.sent).toHaveLength(0);
|
||||
expect(runtime.sessionSnapshot(SESSION).items).toHaveLength(1);
|
||||
expect(runtime.sessionSnapshot(SESSION).sendingId).toBeNull();
|
||||
expect(broadcasts.at(-1).properties.session.sendingId).toBeNull();
|
||||
await settle(40);
|
||||
expect(openCode.state.sent).toHaveLength(1);
|
||||
expect(runtime.sessionSnapshot(SESSION).items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('holds delivery briefly after a user abort', async () => {
|
||||
const { runtime, openCode, emit } = createRuntime();
|
||||
runtime.start();
|
||||
await runtime.enqueue(SESSION, DIRECTORY, item());
|
||||
emit({ type: 'message.updated', properties: { info: { role: 'assistant', sessionID: SESSION, error: { name: 'MessageAbortedError' } } } });
|
||||
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
||||
await settle(10);
|
||||
expect(openCode.state.sent).toHaveLength(0);
|
||||
await settle(80);
|
||||
expect(openCode.state.sent).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('honors a hold until it is released', async () => {
|
||||
const { runtime, openCode, emit } = createRuntime();
|
||||
runtime.start();
|
||||
await runtime.enqueue(SESSION, DIRECTORY, item());
|
||||
runtime.setHold(SESSION, true, 60_000);
|
||||
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
||||
await settle();
|
||||
expect(openCode.state.sent).toHaveLength(0);
|
||||
|
||||
runtime.setHold(SESSION, false);
|
||||
await settle();
|
||||
expect(openCode.state.sent).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('survives a restart and delivers once OpenCode reconnects', async () => {
|
||||
const dataDir = makeDataDir();
|
||||
const first = createRuntime({ dataDir });
|
||||
first.runtime.start();
|
||||
first.openCode.state.statuses = { [SESSION]: { type: 'busy' } };
|
||||
await first.runtime.enqueue(SESSION, DIRECTORY, item({ content: 'persisted', text: 'persisted' }));
|
||||
await first.runtime.flush();
|
||||
first.runtime.stop();
|
||||
|
||||
const second = createRuntime({ dataDir });
|
||||
second.runtime.start();
|
||||
await second.runtime.load();
|
||||
expect(second.runtime.sessionSnapshot(SESSION).items.map((entry) => entry.content)).toEqual(['persisted']);
|
||||
second.connect();
|
||||
await settle();
|
||||
expect(second.openCode.state.sent).toHaveLength(1);
|
||||
expect(second.openCode.state.sent[0].body.parts).toEqual([{ type: 'text', text: 'persisted' }]);
|
||||
});
|
||||
|
||||
it('moves an unreadable queue file aside instead of treating it as empty', async () => {
|
||||
const dataDir = makeDataDir();
|
||||
fs.writeFileSync(path.join(dataDir, 'message-queue.json'), '{ not json');
|
||||
const { runtime } = createRuntime({ dataDir });
|
||||
await runtime.load();
|
||||
expect(runtime.snapshot().sessions).toEqual([]);
|
||||
expect(fs.readdirSync(dataDir).some((name) => name.startsWith('message-queue.json.corrupt-'))).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses to remove or take the item currently being sent', async () => {
|
||||
const { runtime, openCode, emit } = createRuntime();
|
||||
runtime.start();
|
||||
let release;
|
||||
// status map, message tail, then the prompt itself (held open until released)
|
||||
openCode.fetchImpl.mockImplementationOnce(async () => Response.json({}))
|
||||
.mockImplementationOnce(async () => Response.json([]))
|
||||
.mockImplementationOnce(() => new Promise((resolve) => { release = () => resolve(new Response(null, { status: 204 })); }));
|
||||
const { itemId } = await runtime.enqueue(SESSION, DIRECTORY, item());
|
||||
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
||||
await settle();
|
||||
expect(runtime.sessionSnapshot(SESSION).sendingId).toBe(itemId);
|
||||
|
||||
await expect(runtime.remove(SESSION, itemId)).rejects.toMatchObject({ status: 409 });
|
||||
await expect(runtime.take(SESSION, itemId)).rejects.toMatchObject({ status: 409 });
|
||||
const taken = await runtime.takeAll(SESSION);
|
||||
expect(taken.items).toEqual([]);
|
||||
expect(runtime.sessionSnapshot(SESSION).items).toHaveLength(1);
|
||||
|
||||
release();
|
||||
await settle();
|
||||
expect(runtime.sessionSnapshot(SESSION).items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('take hands back the full payload and leaves the rest queued', async () => {
|
||||
const { runtime } = createRuntime();
|
||||
runtime.start();
|
||||
const attachment = { id: 'a1', filename: 'shot.png', mimeType: 'image/png', size: 3, source: 'local', dataUrl: 'data:image/png;base64,AAA=' };
|
||||
const first = await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'with image', attachments: [attachment] }));
|
||||
await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'plain' }));
|
||||
|
||||
expect(runtime.sessionSnapshot(SESSION).items[0].attachments[0]).not.toHaveProperty('dataUrl');
|
||||
const taken = await runtime.take(SESSION, first.itemId);
|
||||
expect(taken.item.attachments[0].dataUrl).toBe(attachment.dataUrl);
|
||||
expect(runtime.sessionSnapshot(SESSION).items.map((entry) => entry.content)).toEqual(['plain']);
|
||||
|
||||
const all = await runtime.takeAll(SESSION);
|
||||
expect(all.items.map((entry) => entry.content)).toEqual(['plain']);
|
||||
expect(runtime.snapshot().sessions).toEqual([]);
|
||||
});
|
||||
|
||||
it('reorders only with a complete permutation', async () => {
|
||||
const { runtime } = createRuntime();
|
||||
runtime.start();
|
||||
const a = await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'a' }));
|
||||
const b = await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'b' }));
|
||||
await expect(runtime.reorder(SESSION, [b.itemId])).rejects.toThrow(TypeError);
|
||||
await runtime.reorder(SESSION, [b.itemId, a.itemId]);
|
||||
expect(runtime.sessionSnapshot(SESSION).items.map((entry) => entry.content)).toEqual(['b', 'a']);
|
||||
});
|
||||
|
||||
it('drops the queue of a deleted session', async () => {
|
||||
const { runtime, emit, broadcasts } = createRuntime();
|
||||
runtime.start();
|
||||
await runtime.enqueue(SESSION, DIRECTORY, item());
|
||||
emit({ type: 'session.deleted', properties: { info: { id: SESSION } } });
|
||||
expect(runtime.snapshot().sessions).toEqual([]);
|
||||
expect(broadcasts.at(-1).properties.session).toMatchObject({ sessionId: SESSION, items: [] });
|
||||
});
|
||||
|
||||
it('dispatches a queued slash command through the command endpoint', async () => {
|
||||
const { runtime, openCode, emit } = createRuntime();
|
||||
runtime.start();
|
||||
openCode.state.commands = [{ name: 'review' }];
|
||||
await runtime.enqueue(SESSION, DIRECTORY, item({ content: '/review src', text: '/review src', sendConfig: { providerID: 'p', modelID: 'm', agent: 'build', variant: 'max' } }));
|
||||
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
||||
await settle();
|
||||
expect(openCode.state.sent).toHaveLength(1);
|
||||
expect(openCode.state.sent[0].path).toBe(`/session/${SESSION}/command`);
|
||||
expect(openCode.state.sent[0].body).toEqual({ command: 'review', arguments: 'src', model: 'p/m', agent: 'build', variant: 'max' });
|
||||
});
|
||||
|
||||
it('attaches pending project knowledge and records its delivery', async () => {
|
||||
const recorded = [];
|
||||
const knowledge = {
|
||||
resolvePendingForSession: async () => ({ text: 'pinned notes', signature: 'sig-1' }),
|
||||
recordDelivered: async (sessionId, directory, signature) => { recorded.push({ sessionId, directory, signature }); },
|
||||
};
|
||||
const { runtime, openCode, emit } = createRuntime({ knowledge });
|
||||
runtime.start();
|
||||
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' }] }));
|
||||
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: 'pinned notes', synthetic: true },
|
||||
{ type: 'agent', name: 'reviewer' },
|
||||
]);
|
||||
expect(recorded).toEqual([{ sessionId: SESSION, directory: DIRECTORY, signature: 'sig-1' }]);
|
||||
});
|
||||
});
|
||||
@@ -208,6 +208,7 @@ Managed health failures are classified as `timeout`, `connection_refused`, `conn
|
||||
- `writeSettingsToDisk(settings)`
|
||||
- `persistSettings(changes)`
|
||||
- Persistent permission auto-accept policy is stored under `permissionAutoAccept`; execution ownership lives in `lib/permission-auto-accept/`.
|
||||
- Queued follow-up messages live in `<data-dir>/message-queue.json`, not in settings; execution ownership lives in `lib/message-queue/`.
|
||||
- Shared sidebar preferences are stored as validated top-level fields: `sidebarProjectDisplayMode`, `sidebarSessionGroupingMode`, `sidebarProjectSortOrder`, and `sidebarShowRecentSection`. Device-local picker selection and sticky-header state do not enter `settings.json`.
|
||||
|
||||
## Public exports (settings-helpers.js)
|
||||
|
||||
@@ -1065,6 +1065,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
|
||||
req.path.startsWith('/api/push') ||
|
||||
req.path.startsWith('/api/notifications') ||
|
||||
req.path.startsWith('/api/permission-auto-accept') ||
|
||||
req.path.startsWith('/api/message-queue') ||
|
||||
req.path.startsWith('/api/provider') ||
|
||||
req.path.startsWith('/api/session-folders') ||
|
||||
req.path.startsWith('/api/small-model') ||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { registerProjectContextRoutes } from '../project-context/routes.js';
|
||||
import { registerAgentMemoryRoutes } from '../agent-memory/routes.js';
|
||||
import { registerSessionKnowledgeRoutes } from '../session-knowledge/routes.js';
|
||||
import { registerPermissionAutoAcceptRoutes } from '../permission-auto-accept/runtime.js';
|
||||
import { registerMessageQueueRoutes } from '../message-queue/runtime.js';
|
||||
import { registerConfigEntityRoutes } from './config-entity-routes.js';
|
||||
import { registerSettingsUtilityRoutes } from './core-routes.js';
|
||||
import { registerProjectIconRoutes } from './project-icon-routes.js';
|
||||
@@ -132,6 +133,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
||||
writeSseEvent,
|
||||
emitSessionCreatedEvent,
|
||||
permissionAutoAcceptRuntime,
|
||||
messageQueueRuntime,
|
||||
} = routeDependencies;
|
||||
|
||||
registerSettingsUtilityRoutes(app, {
|
||||
@@ -141,6 +143,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
||||
});
|
||||
|
||||
registerPermissionAutoAcceptRoutes(app, permissionAutoAcceptRuntime);
|
||||
registerMessageQueueRoutes(app, messageQueueRuntime);
|
||||
|
||||
registerOpenCodeRoutes(app, {
|
||||
crypto,
|
||||
|
||||
@@ -692,8 +692,8 @@ export const createSettingsRuntime = (deps) => {
|
||||
return { settings, changed: false };
|
||||
}
|
||||
|
||||
const defaultLight = 'flexoki-light';
|
||||
const defaultDark = 'flexoki-dark';
|
||||
const defaultLight = 'openchamber-light';
|
||||
const defaultDark = 'openchamber-dark';
|
||||
|
||||
let nextLightThemeId = hasLight ? settings.lightThemeId : undefined;
|
||||
let nextDarkThemeId = hasDark ? settings.darkThemeId : undefined;
|
||||
|
||||
@@ -39,6 +39,35 @@ const createRuntime = async () => {
|
||||
};
|
||||
|
||||
describe('settings runtime', () => {
|
||||
it('uses OpenChamber themes when a new install has no theme preferences', async () => {
|
||||
const { runtime, cleanup } = await createRuntime();
|
||||
try {
|
||||
await expect(runtime.readSettingsFromDiskMigrated()).resolves.toMatchObject({
|
||||
lightThemeId: 'openchamber-light',
|
||||
darkThemeId: 'openchamber-dark',
|
||||
});
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves existing theme preferences during theme migration', async () => {
|
||||
const { runtime, settingsFilePath, cleanup } = await createRuntime();
|
||||
try {
|
||||
await fsPromises.writeFile(settingsFilePath, JSON.stringify({
|
||||
lightThemeId: 'flexoki-light',
|
||||
darkThemeId: 'flexoki-dark',
|
||||
}), 'utf8');
|
||||
|
||||
await expect(runtime.readSettingsFromDiskMigrated()).resolves.toMatchObject({
|
||||
lightThemeId: 'flexoki-light',
|
||||
darkThemeId: 'flexoki-dark',
|
||||
});
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('round-trips shared sidebar preferences through settings.json', async () => {
|
||||
const { runtime, settingsFilePath, cleanup } = await createRuntime();
|
||||
const preferences = {
|
||||
|
||||
@@ -11,6 +11,7 @@ export const createGracefulShutdownRuntime = (dependencies) => {
|
||||
sessionAssistRuntime,
|
||||
sessionGoalRuntime,
|
||||
contextObligatoryRuntime,
|
||||
messageQueueRuntime,
|
||||
scheduledTasksRuntime,
|
||||
getHealthCheckInterval,
|
||||
clearHealthCheckInterval,
|
||||
@@ -47,6 +48,7 @@ export const createGracefulShutdownRuntime = (dependencies) => {
|
||||
sessionAssistRuntime?.stop?.();
|
||||
sessionGoalRuntime?.stop?.();
|
||||
contextObligatoryRuntime?.stop?.();
|
||||
messageQueueRuntime?.stop?.();
|
||||
scheduledTasksRuntime?.stop?.();
|
||||
|
||||
const healthCheckInterval = getHealthCheckInterval();
|
||||
|
||||
Reference in New Issue
Block a user