Merge remote-tracking branch 'origin/main' into fix/ui-thinking-effort-draft-project-rename

This commit is contained in:
Bohdan Triapitsyn
2026-09-04 18:58:08 +03:00
36 changed files with 2300 additions and 212 deletions
@@ -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();