diff options
| author | Adam Malczewski <[email protected]> | 2026-06-21 02:08:44 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-21 02:08:44 +0900 |
| commit | ba47df37f0c89bff4f0c3dd7d0bc2ef6c8062b92 (patch) | |
| tree | 21d87eb847cd526a506cf274467fd1359f349705 /packages/transport-http/src | |
| parent | 75032313a96856a932c109efbbe6b6a7eb782222 (diff) | |
| download | dispatch-ba47df37f0c89bff4f0c3dd7d0bc2ef6c8062b92.tar.gz dispatch-ba47df37f0c89bff4f0c3dd7d0bc2ef6c8062b92.zip | |
feat(message-queue): per-conversation queue + steering injection
A per-conversation message queue (new message-queue extension) holds user
messages enqueued while a turn generates; delivered mid-turn as steering at the
tool-result boundary (or carried to a new turn if no tool call fires).
- kernel: RunTurnInput.drainSteering callback (generic; kernel stays pure)
- wire 0.7.0->0.8.0: QueuedMessage, QueuePayload, TurnSteeringEvent (additive)
- transport-contract 0.11.0->0.12.0: POST /conversations/:id/queue + chat.queue WS op
- message-queue ext: queue state + per-conversation custom surface (rendererId message-queue)
- session-orchestrator: enqueue facade + drainSteering wiring + post-seal carry
- transport-http/ws: queue endpoint + chat.queue op (fixes WsClientMessage exhaustive switch)
- host-bin: register message-queue
1043 vitest + 199 transport bun pass; tsc/biome clean; boot smoke clean.
FE courier: frontend-message-queue-handoff.md.
Diffstat (limited to 'packages/transport-http/src')
| -rw-r--r-- | packages/transport-http/src/app.test.ts | 278 | ||||
| -rw-r--r-- | packages/transport-http/src/app.ts | 36 | ||||
| -rw-r--r-- | packages/transport-http/src/extension.ts | 1 | ||||
| -rw-r--r-- | packages/transport-http/src/index.ts | 2 | ||||
| -rw-r--r-- | packages/transport-http/src/logic.test.ts | 58 | ||||
| -rw-r--r-- | packages/transport-http/src/logic.ts | 35 | ||||
| -rw-r--r-- | packages/transport-http/src/server.bun.test.ts | 3 |
7 files changed, 410 insertions, 3 deletions
diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts index 1f95dd8..49e240f 100644 --- a/packages/transport-http/src/app.test.ts +++ b/packages/transport-http/src/app.test.ts @@ -8,7 +8,11 @@ import type { TurnMetrics, } from "@dispatch/kernel"; import { createThroughputStore, dayKeyOf } from "@dispatch/throughput-store"; -import type { ThroughputResponse } from "@dispatch/transport-contract"; +import type { + QueuedMessage, + QueueResponse, + ThroughputResponse, +} from "@dispatch/transport-contract"; import { describe, expect, it } from "vitest"; import { createApp } from "./app.js"; import type { @@ -132,6 +136,9 @@ function createFakeOrchestrator(events: AgentEvent[]): SessionOrchestrator { isActive() { return false; }, + enqueue() { + return { startedTurn: false, queue: [] }; + }, closeConversation() { return { abortedTurn: false }; }, @@ -162,6 +169,9 @@ function createCapturingOrchestrator(): SessionOrchestrator & { isActive() { return false; }, + enqueue() { + return { startedTurn: false, queue: [] }; + }, closeConversation() { return { abortedTurn: false }; }, @@ -182,6 +192,9 @@ function createThrowingOrchestrator(error: Error): SessionOrchestrator { isActive() { return false; }, + enqueue() { + return { startedTurn: false, queue: [] }; + }, closeConversation() { return { abortedTurn: false }; }, @@ -1319,6 +1332,269 @@ describe("POST /conversations/:id/close", () => { }); }); +describe("POST /conversations/:id/queue", () => { + it("with valid text → 200 + QueueResponse (startedTurn + queue)", async () => { + const queue: readonly QueuedMessage[] = [ + { id: "q1", text: "queued-msg", queuedAt: 1700000000000 }, + ]; + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + enqueue() { + return { startedTurn: false, queue }; + }, + }; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator, + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: "hello" }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as QueueResponse; + expect(body.conversationId).toBe("conv1"); + expect(body.startedTurn).toBe(false); + expect(body.queue).toEqual(queue); + }); + + it("with empty/whitespace text → 400 { error } and enqueue is never called", async () => { + let enqueueCalled = false; + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + enqueue() { + enqueueCalled = true; + return { startedTurn: false, queue: [] }; + }, + }; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator, + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: " " }), + }); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("text"); + expect(enqueueCalled).toBe(false); + }); + + it("with missing text field → 400 { error } and enqueue is never called", async () => { + let enqueueCalled = false; + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + enqueue() { + enqueueCalled = true; + return { startedTurn: false, queue: [] }; + }, + }; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator, + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("text"); + expect(enqueueCalled).toBe(false); + }); + + it("enqueue returns startedTurn:true (was idle) → response echoes it", async () => { + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + enqueue() { + return { startedTurn: true, queue: [] }; + }, + }; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator, + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv-idle/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: "go" }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as QueueResponse; + expect(body.conversationId).toBe("conv-idle"); + expect(body.startedTurn).toBe(true); + expect(body.queue).toEqual([]); + }); + + it("enqueue returns startedTurn:false (was active) → response carries the queue snapshot", async () => { + const queue: readonly QueuedMessage[] = [ + { id: "q1", text: "second", queuedAt: 1700000000000 }, + { id: "q2", text: "third", queuedAt: 1700000001000 }, + ]; + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + enqueue() { + return { startedTurn: false, queue }; + }, + }; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator, + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv-active/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: "steer" }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as QueueResponse; + expect(body.conversationId).toBe("conv-active"); + expect(body.startedTurn).toBe(false); + expect(body.queue).toEqual(queue); + }); + + it("forwards the path conversationId and trimmed text to enqueue", async () => { + const calls: { conversationId: string; text: string }[] = []; + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + enqueue(input) { + calls.push(input); + return { startedTurn: false, queue: [] }; + }, + }; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator, + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv-1/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: " hello world " }), + }); + + expect(res.status).toBe(200); + expect(calls).toHaveLength(1); + expect(calls[0]?.conversationId).toBe("conv-1"); + expect(calls[0]?.text).toBe("hello world"); + }); + + it("returns 400 for invalid JSON body", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "not json", + }); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("JSON"); + }); + + it("returns 400 for a non-string text", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: 42 }), + }); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("text"); + }); + + it("logs an info line on success and never logs the enqueued text", async () => { + const logger = createFakeLogger(); + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + enqueue() { + return { startedTurn: true, queue: [] }; + }, + }; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator, + credentialStore: createFakeCredentialStore([]), + logger, + }); + + await app.request("/conversations/conv1/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: "secret-ish user message" }), + }); + + const infoLogs = logger.records.filter((r) => r.level === "info"); + expect(infoLogs).toHaveLength(1); + expect(infoLogs[0]?.msg).toBe("conversations: enqueued"); + expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1"); + expect(infoLogs[0]?.attrs?.startedTurn).toBe(true); + expect(infoLogs[0]?.attrs?.queueLength).toBe(0); + // Restraint: the user's message text is never logged (mirrors POST /chat). + expect(JSON.stringify(logger.records)).not.toContain("secret-ish user message"); + }); + + it("logs a warn on a malformed body (400)", async () => { + const logger = createFakeLogger(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger, + }); + + await app.request("/conversations/conv1/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: "" }), + }); + + const warnLogs = logger.records.filter((r) => r.level === "warn"); + expect(warnLogs.length).toBeGreaterThanOrEqual(1); + expect(warnLogs[0]?.msg).toBe("conversations/queue: validation failed"); + }); +}); + describe("GET /conversations/:id/cwd", () => { it("returns null when unset", async () => { const app = createApp({ diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts index 2788bf9..8cb85c9 100644 --- a/packages/transport-http/src/app.ts +++ b/packages/transport-http/src/app.ts @@ -7,6 +7,7 @@ import type { LspServerInfo, LspStatusResponse, ModelsResponse, + QueueResponse, ReasoningEffortResponse, ThroughputResponse, WarmResponse, @@ -21,6 +22,7 @@ import { isSinceSeqError, isWindowParamError, parseChatBody, + parseQueueBody, parseReasoningEffortBody, parseSinceSeq, parseWarmBody, @@ -370,6 +372,40 @@ export function createApp(opts: CreateServerOptions): Hono { return c.json(body, 200); }); + app.post("/conversations/:id/queue", async (c) => { + const conversationId = c.req.param("id"); + + let body: unknown; + try { + body = await c.req.json(); + } catch { + log.warn("conversations/queue: invalid JSON body"); + return c.json({ error: "Invalid JSON body" }, 400); + } + + const parsed = parseQueueBody(body); + if (isParseError(parsed)) { + log.warn("conversations/queue: validation failed", { reason: parsed.error }); + return c.json({ error: parsed.error }, 400); + } + + // `enqueue` is synchronous and owns the idle→startTurn vs active→queue + // decision (no separate `isActive` race) — it does not throw for an + // unknown/idle conversation, which instead starts a turn. Mirrors the + // direct sync call used by `POST /conversations/:id/close`. + const { startedTurn, queue } = opts.orchestrator.enqueue({ + conversationId, + text: parsed.text, + }); + log.info("conversations: enqueued", { + conversationId, + startedTurn, + queueLength: queue.length, + }); + const response: QueueResponse = { conversationId, startedTurn, queue }; + return c.json(response, 200); + }); + app.get("/conversations/:id/cwd", async (c) => { const conversationId = c.req.param("id"); try { diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts index ab23b65..3fcc473 100644 --- a/packages/transport-http/src/extension.ts +++ b/packages/transport-http/src/extension.ts @@ -31,6 +31,7 @@ export const manifest: Manifest = { "/conversations/:id/close", "/conversations/:id/cwd", "/conversations/:id/lsp", + "/conversations/:id/queue", "/conversations/:id/reasoning-effort", "/health", "/models", diff --git a/packages/transport-http/src/index.ts b/packages/transport-http/src/index.ts index b231b7e..735dc38 100644 --- a/packages/transport-http/src/index.ts +++ b/packages/transport-http/src/index.ts @@ -5,6 +5,7 @@ export type { ChatCommand, ParseError, ParseResult, + QueueBodyParsed, SinceSeqResult, WarmBodyParsed, WindowParamResult, @@ -17,6 +18,7 @@ export { isValidReasoningEffort, isWindowParamError, parseChatBody, + parseQueueBody, parseReasoningEffortBody, parseSinceSeq, parseWindowParam, diff --git a/packages/transport-http/src/logic.test.ts b/packages/transport-http/src/logic.test.ts index f91b38c..40a82fd 100644 --- a/packages/transport-http/src/logic.test.ts +++ b/packages/transport-http/src/logic.test.ts @@ -8,6 +8,7 @@ import { isValidReasoningEffort, isWindowParamError, parseChatBody, + parseQueueBody, parseReasoningEffortBody, parseSinceSeq, parseWindowParam, @@ -368,3 +369,60 @@ describe("parseReasoningEffortBody", () => { expect(isReasoningEffortParseError(parseReasoningEffortBody("string"))).toBe(true); }); }); + +describe("parseQueueBody", () => { + it("returns error for null body", () => { + const result = parseQueueBody(null); + expect(isParseError(result)).toBe(true); + if (isParseError(result)) { + expect(result.error).toContain("JSON object"); + } + }); + + it("returns error for non-object body", () => { + const result = parseQueueBody("hello"); + expect(isParseError(result)).toBe(true); + }); + + it("returns error when text is missing", () => { + const result = parseQueueBody({}); + expect(isParseError(result)).toBe(true); + if (isParseError(result)) { + expect(result.error).toContain("text"); + } + }); + + it("returns error when text is empty string", () => { + const result = parseQueueBody({ text: "" }); + expect(isParseError(result)).toBe(true); + }); + + it("returns error when text is whitespace only", () => { + const result = parseQueueBody({ text: " " }); + expect(isParseError(result)).toBe(true); + }); + + it("returns error when text is not a string", () => { + const result = parseQueueBody({ text: 42 }); + expect(isParseError(result)).toBe(true); + if (isParseError(result)) { + expect(result.error).toContain("text"); + } + }); + + it("returns the trimmed text for a valid body", () => { + const result = parseQueueBody({ text: "hello" }); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.text).toBe("hello"); + } + }); + + it("trims text whitespace", () => { + const result = parseQueueBody({ text: " hello world " }); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.text).toBe("hello world"); + } + }); +}); diff --git a/packages/transport-http/src/logic.ts b/packages/transport-http/src/logic.ts index e0adfeb..aa5394c 100644 --- a/packages/transport-http/src/logic.ts +++ b/packages/transport-http/src/logic.ts @@ -73,8 +73,8 @@ export function parseChatBody(body: unknown, generateId: () => string): ParseRes return result; } -export function isParseError(result: ParseResult): result is ParseError { - return "error" in result; +export function isParseError<T>(result: T | ParseError): result is ParseError { + return typeof result === "object" && result !== null && "error" in result; } export function serializeEventLine(event: AgentEvent): string { @@ -172,6 +172,37 @@ export function computeExpectedCacheRate( return Math.round((cacheReadTokens / denom) * 100); } +/** + * Parsed body for `POST /conversations/:id/queue` (`QueueRequest`). Only the + * `text` field — `conversationId` comes from the path param, not the body, so it + * is deliberately NOT part of this parse result. + */ +export interface QueueBodyParsed { + readonly text: string; +} + +/** + * Parse + validate a `POST /conversations/:id/queue` body (`QueueRequest`). + * `text` must be a non-empty string after trim — invalid/missing → + * {@link ParseError}. The TRIMMED text is returned (forwarded to + * `orchestrator.enqueue`), mirroring how `parseChatBody` forwards a trimmed + * `message`. + */ +export function parseQueueBody(body: unknown): QueueBodyParsed | ParseError { + if (body === null || typeof body !== "object") { + return { error: "Request body must be a JSON object" }; + } + + const obj = body as Record<string, unknown>; + + const text = obj.text; + if (typeof text !== "string" || text.trim().length === 0) { + return { error: "Field 'text' is required and must be a non-empty string" }; + } + + return { text: text.trim() }; +} + export function parseReasoningEffortBody(body: unknown): ReasoningEffort | ParseError { if (body === null || typeof body !== "object") { return { error: "Request body must be a JSON object" }; diff --git a/packages/transport-http/src/server.bun.test.ts b/packages/transport-http/src/server.bun.test.ts index 36b05a5..151ad24 100644 --- a/packages/transport-http/src/server.bun.test.ts +++ b/packages/transport-http/src/server.bun.test.ts @@ -68,6 +68,9 @@ function fakeOrchestrator(): SessionOrchestrator { isActive() { return false; }, + enqueue() { + return { startedTurn: false, queue: [] }; + }, closeConversation() { return { abortedTurn: false }; }, |
