Merge remote-tracking branch 'origin/main' into fix/ui-thinking-effort-draft-project-rename
This commit is contained in:
@@ -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