summaryrefslogtreecommitdiffhomepage
path: root/packages/transport-http/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-21 02:08:44 +0900
committerAdam Malczewski <[email protected]>2026-06-21 02:08:44 +0900
commitba47df37f0c89bff4f0c3dd7d0bc2ef6c8062b92 (patch)
tree21d87eb847cd526a506cf274467fd1359f349705 /packages/transport-http/src
parent75032313a96856a932c109efbbe6b6a7eb782222 (diff)
downloaddispatch-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.ts278
-rw-r--r--packages/transport-http/src/app.ts36
-rw-r--r--packages/transport-http/src/extension.ts1
-rw-r--r--packages/transport-http/src/index.ts2
-rw-r--r--packages/transport-http/src/logic.test.ts58
-rw-r--r--packages/transport-http/src/logic.ts35
-rw-r--r--packages/transport-http/src/server.bun.test.ts3
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 };
},