summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-28 22:23:25 +0900
committerAdam Malczewski <[email protected]>2026-06-28 22:23:25 +0900
commit81e9e7ee9064b98e06a5f947c67a6ec73b7e578d (patch)
treea7bbc8d912ab0746af16e4ccfd1623f0f990c828
parent1ea99dd6e2cdcdd6e4f581f3908719d8fa3fc780 (diff)
parent8175a3df065155c5a164e29bd1c47aad392ae555 (diff)
downloaddispatch-81e9e7ee9064b98e06a5f947c67a6ec73b7e578d.tar.gz
dispatch-81e9e7ee9064b98e06a5f947c67a6ec73b7e578d.zip
Merge branch 'feature/summon-title' into predev
-rw-r--r--packages/cli/src/args.test.ts43
-rw-r--r--packages/cli/src/args.ts7
-rw-r--r--packages/cli/src/main.ts2
-rw-r--r--packages/cli/src/message.test.ts39
-rw-r--r--packages/cli/src/message.ts2
-rw-r--r--packages/transport-contract/src/contract.types.test.ts21
-rw-r--r--packages/transport-contract/src/index.ts17
-rw-r--r--packages/transport-http/src/app.test.ts148
-rw-r--r--packages/transport-http/src/app.ts17
-rw-r--r--packages/transport-http/src/logic.test.ts50
-rw-r--r--packages/transport-http/src/logic.ts20
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 766abb6..015b385 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 f7afed7..9b1480d 100644
--- a/packages/transport-http/src/app.test.ts
+++ b/packages/transport-http/src/app.test.ts
@@ -799,6 +799,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 bce9125..03b6ec2 100644
--- a/packages/transport-http/src/app.ts
+++ b/packages/transport-http/src/app.ts
@@ -457,6 +457,7 @@ export function createApp(opts: CreateServerOptions): Hono {
reasoningEffort,
workspaceId,
images,
+ title,
} = result;
log.info("chat: request accepted", {
conversationId,
@@ -468,6 +469,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" };