summaryrefslogtreecommitdiffhomepage
path: root/packages/transport-http/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/transport-http/src')
-rw-r--r--packages/transport-http/src/app.test.ts354
-rw-r--r--packages/transport-http/src/app.ts35
-rw-r--r--packages/transport-http/src/extension.ts1
-rw-r--r--packages/transport-http/src/logic.test.ts50
-rw-r--r--packages/transport-http/src/logic.ts22
5 files changed, 462 insertions, 0 deletions
diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts
index 03f1959..557fb44 100644
--- a/packages/transport-http/src/app.test.ts
+++ b/packages/transport-http/src/app.test.ts
@@ -15,6 +15,7 @@ import { DEFAULT_TEMPLATE } from "@dispatch/system-prompt";
import { createThroughputStore, dayKeyOf } from "@dispatch/throughput-store";
import type {
DeleteWorkspaceResponse,
+ QueueCancelResponse,
QueuedMessage,
QueueResponse,
SystemPromptVariable,
@@ -273,6 +274,9 @@ function createFakeOrchestrator(events: AgentEvent[]): SessionOrchestrator {
enqueue() {
return { startedTurn: false, queue: [] };
},
+ cancelQueuedMessage() {
+ return { cancelled: false, queue: [] };
+ },
closeConversation() {
return { abortedTurn: false };
},
@@ -309,6 +313,9 @@ function createCapturingOrchestrator(): SessionOrchestrator & {
enqueue() {
return { startedTurn: false, queue: [] };
},
+ cancelQueuedMessage() {
+ return { cancelled: false, queue: [] };
+ },
closeConversation() {
return { abortedTurn: false };
},
@@ -335,6 +342,9 @@ function createThrowingOrchestrator(error: Error): SessionOrchestrator {
enqueue() {
return { startedTurn: false, queue: [] };
},
+ cancelQueuedMessage() {
+ return { cancelled: false, queue: [] };
+ },
closeConversation() {
return { abortedTurn: false };
},
@@ -539,6 +549,35 @@ function createFakeHeartbeatService(nextRunAt: string | null): HeartbeatService
};
}
+/**
+ * A HeartbeatService fake that CAPTURES the updateConfig call (workspaceId +
+ * partial update) and returns a config echoing the captured update on top of
+ * the defaults — for asserting the PUT /workspaces/:id/heartbeat route forwards
+ * validated fields to the service.
+ */
+function createCapturingHeartbeatService(): HeartbeatService & {
+ readonly captured: { workspaceId: string; update: Record<string, unknown> }[];
+} {
+ const captured: { workspaceId: string; update: Record<string, unknown> }[] = [];
+ const svc: HeartbeatService = {
+ getConfig: async () => DEFAULT_HEARTBEAT_CONFIG,
+ async updateConfig(workspaceId, update) {
+ captured.push({ workspaceId, update: update as Record<string, unknown> });
+ return { ...DEFAULT_HEARTBEAT_CONFIG, ...update };
+ },
+ listRuns: async () => [],
+ stopRun: async () => ({ ok: true }),
+ startAll: async () => {},
+ stopAll: () => {},
+ nextRunAt: async () => null,
+ };
+ return Object.assign(svc, {
+ get captured() {
+ return captured;
+ },
+ });
+}
+
const noopLogger = createFakeLogger();
describe("GET /health", () => {
@@ -789,6 +828,124 @@ describe("POST /chat", () => {
expect(cap.received?.modelName).toBeUndefined();
expect(cap.received?.cwd).toBeUndefined();
});
+
+ it("forwards the title to the orchestrator", async () => {
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1", title: "My Task" }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(cap.received).toBeDefined();
+ expect(cap.received?.title).toBe("My Task");
+ });
+
+ it("forwards a trimmed title to the orchestrator", async () => {
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1", title: " spaced " }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(cap.received?.title).toBe("spaced");
+ });
+
+ it("does not forward a title when omitted", async () => {
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(cap.received?.title).toBeUndefined();
+ });
+
+ it("does not forward a title for a whitespace-only title", async () => {
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1", title: " " }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(cap.received?.title).toBeUndefined();
+ });
+
+ it("returns 400 when title is not a string", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1", title: 42 }),
+ });
+
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("title");
+ });
+
+ it("does not call setConversationTitle itself (the orchestrator owns it)", async () => {
+ let setTitleCalled = false;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setConversationTitle() {
+ setTitleCalled = true;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1", title: "My Task" }),
+ });
+
+ expect(res.status).toBe(200);
+ // The route must NOT pre-create the meta — that would bypass the
+ // orchestrator's new-conversation workspace/system-prompt init. The
+ // orchestrator sets the title after workspace setup instead.
+ expect(setTitleCalled).toBe(false);
+ });
});
describe("POST /chat/warm", () => {
@@ -2069,6 +2226,142 @@ describe("POST /conversations/:id/queue", () => {
});
});
+describe("DELETE /conversations/:id/queue/:messageId", () => {
+ it("when a message is cancelled → 200 + QueueCancelResponse (cancelled:true + post-cancel queue)", async () => {
+ const remaining: readonly QueuedMessage[] = [
+ { id: "q1", text: "kept", queuedAt: 1700000000000 },
+ ];
+ let received: { conversationId: string; messageId: string } | undefined;
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ cancelQueuedMessage(input) {
+ received = input;
+ return { cancelled: true, queue: remaining };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/queue/q2", {
+ method: "DELETE",
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as QueueCancelResponse;
+ expect(body.conversationId).toBe("conv1");
+ expect(body.cancelled).toBe(true);
+ expect(body.queue).toEqual(remaining);
+ // forwards the path conversationId + messageId
+ expect(received?.conversationId).toBe("conv1");
+ expect(received?.messageId).toBe("q2");
+ });
+
+ it("when the message is not in the queue → 200 cancelled:false (idempotent, not an error)", async () => {
+ const queue: readonly QueuedMessage[] = [
+ { id: "q1", text: "still-queued", queuedAt: 1700000000000 },
+ ];
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ cancelQueuedMessage() {
+ return { cancelled: false, queue };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/queue/missing", {
+ method: "DELETE",
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as QueueCancelResponse;
+ expect(body.cancelled).toBe(false);
+ expect(body.queue).toEqual(queue);
+ });
+
+ it("when the queue ext is not loaded → 200 cancelled:false, empty queue (degraded)", async () => {
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ cancelQueuedMessage() {
+ return { cancelled: false, queue: [] };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/queue/whatever", {
+ method: "DELETE",
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as QueueCancelResponse;
+ expect(body.cancelled).toBe(false);
+ expect(body.queue).toEqual([]);
+ });
+
+ it("delegates the cancel to the orchestrator (never reads the body)", async () => {
+ let calls = 0;
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ cancelQueuedMessage() {
+ calls += 1;
+ return { cancelled: true, queue: [] };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ // No Content-Type / body — the endpoint takes the messageId from the path.
+ const res = await app.request("/conversations/conv-x/queue/m1", {
+ method: "DELETE",
+ });
+
+ expect(res.status).toBe(200);
+ expect(calls).toBe(1);
+ });
+
+ it("logs an info line on success and never logs the message text", async () => {
+ const logger = createFakeLogger();
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ cancelQueuedMessage() {
+ return { cancelled: true, queue: [] };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger,
+ });
+
+ await app.request("/conversations/conv1/queue/q-secret", { method: "DELETE" });
+
+ const infoLogs = logger.records.filter((r) => r.level === "info");
+ expect(infoLogs).toHaveLength(1);
+ expect(infoLogs[0]?.msg).toBe("conversations: cancelled queued message");
+ expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1");
+ expect(infoLogs[0]?.attrs?.messageId).toBe("q-secret");
+ expect(infoLogs[0]?.attrs?.cancelled).toBe(true);
+ });
+});
+
describe("GET /conversations/:id/cwd", () => {
it("returns null when unset", async () => {
const app = createApp({
@@ -4541,3 +4834,64 @@ describe("GET /workspaces/:id/heartbeat/next-run", () => {
expect(body.nextRunAt).toBeNull();
});
});
+
+describe("PUT /workspaces/:id/heartbeat", () => {
+ it("forwards inactiveOnly to the service and echoes it in the response", async () => {
+ const hb = createCapturingHeartbeatService();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ heartbeatService: hb,
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/ws-1/heartbeat", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ inactiveOnly: false }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { inactiveOnly: boolean };
+ expect(body.inactiveOnly).toBe(false);
+ expect(hb.captured).toHaveLength(1);
+ expect(hb.captured[0]?.workspaceId).toBe("ws-1");
+ expect(hb.captured[0]?.update.inactiveOnly).toBe(false);
+ });
+
+ it("rejects a non-boolean inactiveOnly with 400", async () => {
+ const hb = createCapturingHeartbeatService();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ heartbeatService: hb,
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/ws-1/heartbeat", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ inactiveOnly: "yes" }),
+ });
+ expect(res.status).toBe(400);
+ // The service was NOT called (validation happened first).
+ expect(hb.captured).toHaveLength(0);
+ });
+
+ it("omits inactiveOnly from the forwarded update when absent (leaves it unchanged)", async () => {
+ const hb = createCapturingHeartbeatService();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ heartbeatService: hb,
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/ws-1/heartbeat", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ enabled: true }),
+ });
+ expect(res.status).toBe(200);
+ expect(hb.captured[0]?.update.inactiveOnly).toBeUndefined();
+ });
+});
diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts
index 656be9d..32a92f1 100644
--- a/packages/transport-http/src/app.ts
+++ b/packages/transport-http/src/app.ts
@@ -29,6 +29,7 @@ import type {
ModelResponse,
ModelsResponse,
OpenConversationResponse,
+ QueueCancelResponse,
QueueResponse,
ReasoningEffortResponse,
SetCompactPercentRequest,
@@ -456,6 +457,7 @@ export function createApp(opts: CreateServerOptions): Hono {
reasoningEffort,
workspaceId,
images,
+ title,
} = result;
log.info("chat: request accepted", {
conversationId,
@@ -516,6 +518,7 @@ export function createApp(opts: CreateServerOptions): Hono {
...(reasoningEffort !== undefined ? { reasoningEffort } : {}),
...(workspaceId !== undefined ? { workspaceId } : {}),
...(images !== undefined ? { images } : {}),
+ ...(title !== undefined ? { title } : {}),
};
opts.orchestrator
@@ -796,6 +799,28 @@ export function createApp(opts: CreateServerOptions): Hono {
return c.json(response, 200);
});
+ app.delete("/conversations/:id/queue/:messageId", (c) => {
+ const conversationId = c.req.param("id");
+ const messageId = c.req.param("messageId");
+
+ // `cancelQueuedMessage` is synchronous and owns the lookup + removal (no
+ // separate race — the pure `cancel` is idempotent). It does not throw for an
+ // unknown/idle conversation, which instead returns cancelled:false. Mirrors
+ // the direct sync call used by `POST /conversations/:id/queue`.
+ const { cancelled, queue } = opts.orchestrator.cancelQueuedMessage({
+ conversationId,
+ messageId,
+ });
+ log.info("conversations: cancelled queued message", {
+ conversationId,
+ messageId,
+ cancelled,
+ queueLength: queue.length,
+ });
+ const response: QueueCancelResponse = { conversationId, cancelled, queue };
+ return c.json(response, 200);
+ });
+
app.get("/conversations/:id/cwd", async (c) => {
const conversationId = c.req.param("id");
try {
@@ -1673,6 +1698,16 @@ export function createApp(opts: CreateServerOptions): Hono {
update.enabled = obj.enabled;
}
+ // inactiveOnly: when true (the default), the heartbeat skips a fire while
+ // the configured workspace has active agents. A boolean; absent leaves it
+ // unchanged.
+ if (obj.inactiveOnly !== undefined) {
+ if (typeof obj.inactiveOnly !== "boolean") {
+ return c.json({ error: "Field 'inactiveOnly' must be a boolean" }, 400);
+ }
+ update.inactiveOnly = obj.inactiveOnly;
+ }
+
if (obj.systemPrompt !== undefined) {
if (typeof obj.systemPrompt !== "string") {
return c.json({ error: "Field 'systemPrompt' must be a string" }, 400);
diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts
index f424e42..effbadd 100644
--- a/packages/transport-http/src/extension.ts
+++ b/packages/transport-http/src/extension.ts
@@ -56,6 +56,7 @@ export const manifest: Manifest = {
"/conversations/:id/mcp",
"/conversations/:id/open",
"/conversations/:id/queue",
+ "/conversations/:id/queue/:messageId",
"/conversations/:id/reasoning-effort",
"/conversations/:id/status",
"/conversations/:id/stop",
diff --git a/packages/transport-http/src/logic.test.ts b/packages/transport-http/src/logic.test.ts
index 67632f3..271ee96 100644
--- a/packages/transport-http/src/logic.test.ts
+++ b/packages/transport-http/src/logic.test.ts
@@ -183,6 +183,56 @@ describe("parseChatBody", () => {
}
});
+ // ── title ────────────────────────────────────────────────────────────────
+
+ it("extracts title when present", () => {
+ const result = parseChatBody({ message: "hi", title: "My Task" }, fakeId);
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.title).toBe("My Task");
+ }
+ });
+
+ it("trims title whitespace", () => {
+ const result = parseChatBody({ message: "hi", title: " spaced title " }, fakeId);
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.title).toBe("spaced title");
+ }
+ });
+
+ it("omits title when absent (backward compatible)", () => {
+ const result = parseChatBody({ message: "hi" }, fakeId);
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.title).toBeUndefined();
+ }
+ });
+
+ it("omits title when whitespace-only (treated as absent)", () => {
+ const result = parseChatBody({ message: "hi", title: " " }, fakeId);
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.title).toBeUndefined();
+ }
+ });
+
+ it("omits title when empty string (treated as absent)", () => {
+ const result = parseChatBody({ message: "hi", title: "" }, fakeId);
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.title).toBeUndefined();
+ }
+ });
+
+ it("returns error when title is not a string", () => {
+ const result = parseChatBody({ message: "hi", title: 42 }, fakeId);
+ expect(isParseError(result)).toBe(true);
+ if (isParseError(result)) {
+ expect(result.error).toContain("title");
+ }
+ });
+
// ── images ──────────────────────────────────────────────────────────────
it("parses images array with data URLs", () => {
diff --git a/packages/transport-http/src/logic.ts b/packages/transport-http/src/logic.ts
index c97f320..c703049 100644
--- a/packages/transport-http/src/logic.ts
+++ b/packages/transport-http/src/logic.ts
@@ -56,6 +56,16 @@ export interface ChatCommand {
readonly reasoningEffort?: ReasoningEffort;
readonly workspaceId?: string;
/**
+ * A human-readable title for the conversation tab, set at creation time.
+ * Parsed from the `ChatRequest.title` field; trimmed server-side. A
+ * whitespace-only value is treated as absent (omitted) so the auto-derived
+ * title applies. Forwarded to the orchestrator, which persists it via the
+ * conversation store's `setConversationTitle` AFTER the new-conversation
+ * workspace setup (so workspace assignment / first-turn system-prompt
+ * construction are not skipped) and before the first message append.
+ */
+ readonly title?: string;
+ /**
* Images attached to this turn (data URLs or http URLs). Parsed from the
* `ChatRequest.images` field; forwarded to the orchestrator which converts
* them to `image` chunks on the user message. Each entry must have a non-empty
@@ -128,6 +138,18 @@ export function parseChatBody(body: unknown, generateId: () => string): ParseRes
(result as { workspaceId?: string }).workspaceId = obj.workspaceId;
}
+ if (obj.title !== undefined) {
+ if (typeof obj.title !== "string") {
+ return { error: "Field 'title' must be a string" };
+ }
+ const title = obj.title.trim();
+ // A whitespace-only title is treated as absent so the auto-derived title
+ // applies (mirrors omitting the field) — never persist an empty title.
+ if (title.length > 0) {
+ (result as { title?: string }).title = title;
+ }
+ }
+
if (obj.images !== undefined) {
if (!Array.isArray(obj.images)) {
return { error: "Field 'images' must be an array" };