diff options
| author | Adam Malczewski <[email protected]> | 2026-06-28 21:56:23 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-28 21:56:23 +0900 |
| commit | 8175a3df065155c5a164e29bd1c47aad392ae555 (patch) | |
| tree | 2c5d02672d14c9fe51b785196c28d6d9f72229fd | |
| parent | 6dd9ea9b935e5011c16faed6c869c976cf5ff172 (diff) | |
| download | dispatch-8175a3df065155c5a164e29bd1c47aad392ae555.tar.gz dispatch-8175a3df065155c5a164e29bd1c47aad392ae555.zip | |
feat(cli): add --title flag to set tab title when summoning
Add a --title <title> flag to the dispatch CLI so a summoned agent's
conversation/tab gets a human-readable title at creation time instead of the
auto-derived default (Untitled until the first message append).
The title is carried as an additive optional ChatRequest.title field (the
HTTP client-server contract), parsed + trimmed server-side in parseChatBody,
and persisted via the conversation store's setConversationTitle in the POST
/chat route BEFORE the turn starts — so the title is set atomically with
creation and before --open signals the frontend to open the tab. No second
HTTP round-trip is needed.
A whitespace-only title is treated as absent (auto-derive); a non-string
title yields HTTP 400. A title-set failure is logged but never blocks the
turn (the title is a nicety, the answer is not). The change is purely
additive — no existing behavior or assertion changes.
Verification: typecheck EXIT 0; test 2021 passed | 6 skipped | 0 failed;
check EXIT 0 (12 pre-existing warnings in untouched files).
| -rw-r--r-- | packages/cli/src/args.test.ts | 43 | ||||
| -rw-r--r-- | packages/cli/src/args.ts | 7 | ||||
| -rw-r--r-- | packages/cli/src/main.ts | 2 | ||||
| -rw-r--r-- | packages/cli/src/message.test.ts | 39 | ||||
| -rw-r--r-- | packages/cli/src/message.ts | 2 | ||||
| -rw-r--r-- | packages/transport-contract/src/contract.types.test.ts | 21 | ||||
| -rw-r--r-- | packages/transport-contract/src/index.ts | 17 | ||||
| -rw-r--r-- | packages/transport-http/src/app.test.ts | 148 | ||||
| -rw-r--r-- | packages/transport-http/src/app.ts | 17 | ||||
| -rw-r--r-- | packages/transport-http/src/logic.test.ts | 50 | ||||
| -rw-r--r-- | packages/transport-http/src/logic.ts | 20 |
11 files changed, 365 insertions, 1 deletions
diff --git a/packages/cli/src/args.test.ts b/packages/cli/src/args.test.ts index e6b43cf..14c4ffc 100644 --- a/packages/cli/src/args.test.ts +++ b/packages/cli/src/args.test.ts @@ -216,6 +216,49 @@ describe("parseArgs", () => { expect(result.kind).toBe("error"); if (result.kind === "error") expect(result.message).toContain("--workspace requires a value"); }); + + it("parses --title flag", () => { + const result = parseArgs(["m", "--text", "x", "--title", "My Task"], { defaultServer }); + expect(result).toEqual({ + kind: "chat", + server: "http://localhost:24203", + modelName: "m", + text: "x", + file: undefined, + cwd: undefined, + conversationId: undefined, + reasoningEffort: undefined, + showReasoning: false, + open: false, + title: "My Task", + }); + }); + + it("parses --title with --workspace together", () => { + const result = parseArgs(["m", "--text", "x", "--workspace", "ws", "--title", "T"], { + defaultServer, + }); + expect(result.kind).toBe("chat"); + if (result.kind === "chat") { + expect(result.workspaceId).toBe("ws"); + expect(result.title).toBe("T"); + } + }); + + it("omits title when --title is not given", () => { + const result = parseArgs(["m", "--text", "x"], { defaultServer }); + expect(result.kind).toBe("chat"); + if (result.kind === "chat") { + expect(result.title).toBeUndefined(); + expect(result).not.toHaveProperty("title"); + } + }); + + it("errors when --title has no value", () => { + const result = parseArgs(["m", "--text", "x", "--title"], { defaultServer }); + expect(result.kind).toBe("error"); + if (result.kind === "error") expect(result.message).toContain("--title requires a value"); + }); }); describe("list", () => { diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts index 52f1fba..581d4c2 100644 --- a/packages/cli/src/args.ts +++ b/packages/cli/src/args.ts @@ -27,6 +27,7 @@ export type ParsedCommand = readonly showReasoning: boolean; readonly open: boolean; readonly workspaceId?: string | undefined; + readonly title?: string | undefined; } | { readonly kind: "list"; @@ -307,6 +308,7 @@ export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedComma let open = false; let server = opts.defaultServer; let workspaceId: string | undefined; + let title: string | undefined; for (let i = 1; i < argv.length; i++) { const arg = argv[i] as string; @@ -338,6 +340,10 @@ export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedComma case "--open": open = true; break; + case "--title": + if (i + 1 >= argv.length) return { kind: "error", message: "--title requires a value" }; + title = argv[++i]; + break; case "--effort": if (i + 1 >= argv.length) return { @@ -383,5 +389,6 @@ export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedComma showReasoning, open, ...(workspaceId !== undefined && { workspaceId }), + ...(title !== undefined && { title }), }; } diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 9fca347..c4fff1e 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -30,7 +30,7 @@ const USAGE = `Usage: dispatch read <conversationId> [--server <url>] dispatch open <conversationId> [--server <url>] dispatch send <conversationId> --text "..." [--file <path>] [--queue] [--open] [--cwd <dir>] [--effort <level>] [--workspace <id>] [--server <url>] - dispatch <modelName> --text "..." [--file <path>] [--cwd <dir>] [--conversation <id>] [--effort <level>] [--workspace <id>] [--server <url>] [--show-reasoning] [--open] + dispatch <modelName> --text "..." [--file <path>] [--cwd <dir>] [--conversation <id>] [--effort <level>] [--workspace <id>] [--title <title>] [--server <url>] [--show-reasoning] [--open] dispatch --help Effort levels: low, medium, high (default), xhigh, max`; diff --git a/packages/cli/src/message.test.ts b/packages/cli/src/message.test.ts index 536d64f..2deb197 100644 --- a/packages/cli/src/message.test.ts +++ b/packages/cli/src/message.test.ts @@ -111,6 +111,22 @@ describe("buildChatRequest", () => { ); expect(req).not.toHaveProperty("workspaceId"); }); + + it("includes title when provided", () => { + const req = buildChatRequest( + { modelName: "m", text: "x", title: "My Task", showReasoning: false }, + { cwd: "/work", message: "x" }, + ); + expect(req.title).toBe("My Task"); + }); + + it("omits title when not provided", () => { + const req = buildChatRequest( + { modelName: "m", text: "x", showReasoning: false }, + { cwd: "/work", message: "x" }, + ); + expect(req).not.toHaveProperty("title"); + }); }); describe("workspace flag → ChatRequest", () => { @@ -145,3 +161,26 @@ describe("workspace flag → ChatRequest", () => { expect(req.workspaceId).toBe("shorthand"); }); }); + +describe("title flag → ChatRequest", () => { + const defaultServer = "http://localhost:24203"; + + it("--title flag sets title on request", () => { + const parsed = parseArgs(["my-model", "--text", "hi", "--title", "My Task"], { + defaultServer, + }); + expect(parsed.kind).toBe("chat"); + if (parsed.kind !== "chat") return; + const req = buildChatRequest(parsed, { cwd: "/work", message: "hi" }); + expect(req.title).toBe("My Task"); + }); + + it("--title flag omitted sends no title", () => { + const parsed = parseArgs(["my-model", "--text", "hi"], { defaultServer }); + expect(parsed.kind).toBe("chat"); + if (parsed.kind !== "chat") return; + const req = buildChatRequest(parsed, { cwd: "/work", message: "hi" }); + expect(req.title).toBeUndefined(); + expect(req).not.toHaveProperty("title"); + }); +}); diff --git a/packages/cli/src/message.ts b/packages/cli/src/message.ts index ddbec6b..5db9966 100644 --- a/packages/cli/src/message.ts +++ b/packages/cli/src/message.ts @@ -39,6 +39,7 @@ interface ChatCmd { readonly conversationId?: string | undefined; readonly reasoningEffort?: ReasoningEffort | undefined; readonly workspaceId?: string | undefined; + readonly title?: string | undefined; readonly showReasoning: boolean; } @@ -55,5 +56,6 @@ export function buildChatRequest(cmd: ChatCmd, ctx: BuildCtx): ChatRequest { ...(cmd.cwd !== undefined ? { cwd: cmd.cwd } : { cwd: ctx.cwd }), ...(cmd.reasoningEffort !== undefined && { reasoningEffort: cmd.reasoningEffort }), ...(cmd.workspaceId !== undefined && { workspaceId: cmd.workspaceId }), + ...(cmd.title !== undefined && { title: cmd.title }), }; } diff --git a/packages/transport-contract/src/contract.types.test.ts b/packages/transport-contract/src/contract.types.test.ts index 34ff544..3cc1b1e 100644 --- a/packages/transport-contract/src/contract.types.test.ts +++ b/packages/transport-contract/src/contract.types.test.ts @@ -68,6 +68,17 @@ const _chatWithHttpImage: ChatRequest = { images: [{ url: "https://example.com/diagram.png" }], }; +// ─── ChatRequest.title (additive optional) ─────────────────────────────────── + +const _chatWithTitle: ChatRequest = { + message: "implement the feature", + title: "Summon: add --title flag", +}; + +const _chatWithoutTitle: ChatRequest = { + message: "hello", +}; + // ─── Computer list / single response ───────────────────────────────────────── const _computer: Computer = { @@ -285,6 +296,16 @@ describe("transport-contract types compile and are exported", () => { expect(_chatWithHttpImage.images?.[0]?.mimeType).toBeUndefined(); }); + // ─── ChatRequest.title (additive optional) ──────────────────────────────── + + it("ChatRequest: title is additive optional (omittable)", () => { + expect(_chatWithoutTitle.title).toBeUndefined(); + }); + + it("ChatRequest: carries title when set", () => { + expect(_chatWithTitle.title).toBe("Summon: add --title flag"); + }); + it("ModelsResponse: ModelMetadata carries optional vision flag", () => { const resp: ModelsResponse = { models: ["umans/kimi-k2.7", "umans/glm-5.2"], diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts index 797ad22..4f31eaa 100644 --- a/packages/transport-contract/src/index.ts +++ b/packages/transport-contract/src/index.ts @@ -121,6 +121,23 @@ export interface ChatRequest { * defaultCwd = null). */ readonly workspaceId?: string; + + /** + * A human-readable title for the conversation tab — set at creation time + * (before the turn starts) via the conversation store's + * `setConversationTitle`, so the tab shows it immediately instead of the + * default derived from the first message (`"Untitled"` until the first + * append). Omit to keep the auto-derived title. When present, the value is + * trimmed server-side; a whitespace-only value is treated as absent + * (auto-derive). A non-string value → HTTP 400 `{ error }`. + * + * Backward compatible — clients that omit it are unaffected. Mirrors the + * dedicated `PUT /conversations/:id/title` endpoint but is atomic with + * conversation creation (no second round-trip), so the title is persisted + * before the turn's first message is appended (and thus before the tab is + * opened with `--open`). + */ + readonly title?: string; } /** diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts index 03f1959..ce429df 100644 --- a/packages/transport-http/src/app.test.ts +++ b/packages/transport-http/src/app.test.ts @@ -789,6 +789,154 @@ describe("POST /chat", () => { expect(cap.received?.modelName).toBeUndefined(); expect(cap.received?.cwd).toBeUndefined(); }); + + it("sets the conversation title from the request before the turn", async () => { + const calls: { conversationId: string; title: string }[] = []; + const store: ConversationStore = { + ...createFakeConversationStore(), + async setConversationTitle(conversationId, title) { + calls.push({ conversationId, title }); + }, + }; + 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); + expect(calls).toEqual([{ conversationId: "conv1", title: "My Task" }]); + }); + + it("forwards a trimmed title to setConversationTitle", async () => { + const calls: { conversationId: string; title: string }[] = []; + const store: ConversationStore = { + ...createFakeConversationStore(), + async setConversationTitle(conversationId, title) { + calls.push({ conversationId, title }); + }, + }; + 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: " spaced " }), + }); + + expect(res.status).toBe(200); + expect(calls).toEqual([{ conversationId: "conv1", title: "spaced" }]); + }); + + it("does not call setConversationTitle when title is omitted", 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" }), + }); + + expect(res.status).toBe(200); + expect(setTitleCalled).toBe(false); + }); + + it("does not call setConversationTitle for a whitespace-only title", 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: " " }), + }); + + expect(res.status).toBe(200); + expect(setTitleCalled).toBe(false); + }); + + it("returns 400 when title is not a string", 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: 42 }), + }); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("title"); + expect(setTitleCalled).toBe(false); + }); + + it("proceeds with the turn even if setConversationTitle throws", async () => { + const store: ConversationStore = { + ...createFakeConversationStore(), + async setConversationTitle() { + throw new Error("store unavailable"); + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([ + { type: "done", conversationId: "conv1", turnId: "t1", reason: "stop" }, + ]), + 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); + const text = await res.text(); + expect(text.trim().split("\n")).toHaveLength(1); + }); }); describe("POST /chat/warm", () => { diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts index 656be9d..06e88a0 100644 --- a/packages/transport-http/src/app.ts +++ b/packages/transport-http/src/app.ts @@ -456,6 +456,7 @@ export function createApp(opts: CreateServerOptions): Hono { reasoningEffort, workspaceId, images, + title, } = result; log.info("chat: request accepted", { conversationId, @@ -467,6 +468,22 @@ export function createApp(opts: CreateServerOptions): Hono { imageCount: images?.length ?? 0, }); + // Persist an explicit title BEFORE the turn starts so the tab shows it + // immediately (and before `--open` signals the frontend to open it). The + // store creates the conversation meta if none exists yet; a subsequent + // append preserves a non-"Untitled" title. A title-set failure is logged + // but never blocks the turn — the title is a nicety, the answer is not. + if (title !== undefined) { + try { + await opts.conversationStore.setConversationTitle(conversationId, title); + log.info("chat: title set", { conversationId }); + } catch (err) { + log.warn("chat: title set failure", { + error: err instanceof Error ? err.message : String(err), + }); + } + } + const events: AgentEvent[] = []; let controllerRef: ReadableStreamDefaultController<Uint8Array> | undefined; let streamClosed = false; 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..5cf96cc 100644 --- a/packages/transport-http/src/logic.ts +++ b/packages/transport-http/src/logic.ts @@ -56,6 +56,14 @@ 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 `/chat` route which persists it via the + * conversation store's `setConversationTitle` before the turn starts. + */ + 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 +136,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" }; |
