diff options
| author | Adam Malczewski <[email protected]> | 2026-07-01 03:30:42 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-07-01 03:30:42 +0900 |
| commit | 6bca0a8b65506239b0ce72d7f86ba96f825152b1 (patch) | |
| tree | 7b6b3d0edeb2239acbaa605ba898abaf1711a12c | |
| parent | 566c64033ad79538f9208fc3ef9477cd8a58f7da (diff) | |
| parent | a2e3bad36fd236835423e3e9124b8496b6d9c795 (diff) | |
| download | dispatch-6bca0a8b65506239b0ce72d7f86ba96f825152b1.tar.gz dispatch-6bca0a8b65506239b0ce72d7f86ba96f825152b1.zip | |
Merge branch 'feature/summon-title' into predev
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.test.ts | 188 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.ts | 41 | ||||
| -rw-r--r-- | packages/transport-contract/src/index.ts | 22 | ||||
| -rw-r--r-- | packages/transport-http/src/app.test.ts | 90 | ||||
| -rw-r--r-- | packages/transport-http/src/app.ts | 17 | ||||
| -rw-r--r-- | packages/transport-http/src/logic.ts | 6 |
6 files changed, 273 insertions, 91 deletions
diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts index e67d1b7..c4be03c 100644 --- a/packages/session-orchestrator/src/orchestrator.test.ts +++ b/packages/session-orchestrator/src/orchestrator.test.ts @@ -3879,6 +3879,194 @@ describe("system prompt: regular turn flow", () => { }); }); +describe("title (summon-title): deferred until after workspace initialization", () => { + // Regression: an earlier implementation set the title in the HTTP /chat + // route BEFORE the turn started, which pre-created the conversation meta + // and made the orchestrator's `meta === null` newness check falsely report + // an EXISTING conversation — so ensureWorkspace / setWorkspaceId / the + // first-turn system-prompt construct were ALL skipped. The fix defers the + // title set into workspaceSetupPromise, AFTER the newness check + workspace + // assignment, so a titled new conversation is still initialized correctly. + + /** Wrap the in-memory store to record the ORDER of init-relevant calls. */ + function createCallRecordingStore() { + const base = createInMemoryStore(); + const calls: string[] = []; + const titleCalls: { conversationId: string; title: string }[] = []; + return { + store: { + ...base, + async getConversationMeta(conversationId: string) { + calls.push(`getMeta:${conversationId}`); + return base.getConversationMeta(conversationId); + }, + async ensureWorkspace(id: string) { + calls.push(`ensureWorkspace:${id}`); + return base.ensureWorkspace(id); + }, + async setWorkspaceId(conversationId: string, workspaceId: string) { + calls.push(`setWorkspaceId:${workspaceId}`); + await base.setWorkspaceId(conversationId, workspaceId); + }, + async setConversationTitle(conversationId: string, title: string) { + calls.push(`setTitle:${title}`); + titleCalls.push({ conversationId, title }); + await base.setConversationTitle(conversationId, title); + }, + } as ConversationStore, + calls, + titleCalls, + }; + } + + it("titled new conversation: workspace assigned, system prompt constructed, title set", async () => { + const { store, calls, titleCalls } = createCallRecordingStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captureRunTurn } = createCapturingRunTurn(); + const constructCalls: string[] = []; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + resolveSystemPrompt: () => + createFakeSystemPromptService(async (conversationId) => { + constructCalls.push(conversationId); + return "CONSTRUCTED_PROMPT"; + }), + }); + + await orchestrator.handleMessage({ + conversationId: "conv-title-new", + text: "hi", + onEvent: () => {}, + title: "My Task", + workspaceId: "my-workspace", + }); + + // The bug: workspace init was skipped. It must NOT be. + expect(calls).toContain("ensureWorkspace:my-workspace"); + expect(calls).toContain("setWorkspaceId:my-workspace"); + // First-turn system prompt construct runs (proves isNewConversation was + // true — the newness check was not fooled by a pre-created meta). + expect(constructCalls).toEqual(["conv-title-new"]); + // The title is persisted. + expect(titleCalls).toEqual([{ conversationId: "conv-title-new", title: "My Task" }]); + }); + + it("title is set AFTER the newness check + workspace assignment (order)", async () => { + const { store, calls } = createCallRecordingStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-title-order", + text: "hi", + onEvent: () => {}, + title: "Ordered", + }); + + const getMetaIdx = calls.findIndex((c) => c.startsWith("getMeta:")); + const ensureIdx = calls.findIndex((c) => c.startsWith("ensureWorkspace:")); + const setWsIdx = calls.findIndex((c) => c.startsWith("setWorkspaceId:")); + const setTitleIdx = calls.findIndex((c) => c.startsWith("setTitle:")); + expect(getMetaIdx).toBeGreaterThanOrEqual(0); + expect(ensureIdx).toBeGreaterThan(getMetaIdx); + expect(setWsIdx).toBeGreaterThan(ensureIdx); + expect(setTitleIdx).toBeGreaterThan(setWsIdx); + }); + + it("no title: setConversationTitle is not called", async () => { + const { store, calls } = createCallRecordingStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-no-title", + text: "hi", + onEvent: () => {}, + }); + + expect(calls.some((c) => c.startsWith("setTitle:"))).toBe(false); + }); + + it("existing conversation with a title: workspace NOT re-assigned, title still set", async () => { + const { store, calls, titleCalls } = createCallRecordingStore(); + // Seed an existing conversation (meta non-null, workspace already set). + await store.setWorkspaceId("conv-title-existing", "prior-workspace"); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-title-existing", + text: "hi", + onEvent: () => {}, + title: "Renamed", + }); + + // Existing conversation: workspace init must not run again. + expect(calls.some((c) => c.startsWith("ensureWorkspace:"))).toBe(false); + // But the title is still applied (rename on an existing conversation). + expect(titleCalls).toEqual([{ conversationId: "conv-title-existing", title: "Renamed" }]); + }); + + it("turn still completes if setConversationTitle throws", async () => { + const base = createInMemoryStore(); + const store: ConversationStore = { + ...base, + async setConversationTitle() { + throw new Error("title store unavailable"); + }, + }; + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-title-throws", + text: "hi", + onEvent: () => {}, + title: "Resilient", + }); + + // The turn ran despite the title-set failure. + expect(captured).toHaveLength(1); + }); +}); + describe("system prompt: compaction flow", () => { function seedHistory( store: ReturnType<typeof createInMemoryStore>, diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts index ffc5d58..badb8dd 100644 --- a/packages/session-orchestrator/src/orchestrator.ts +++ b/packages/session-orchestrator/src/orchestrator.ts @@ -131,6 +131,16 @@ export interface StartTurnInput { * system-prompt service when loaded). */ readonly systemPrompt?: string; + /** + * A human-readable title for the conversation tab. When provided, it is + * persisted via `setConversationTitle` AFTER the new-conversation workspace + * setup resolves (so the `meta === null` newness detection still fires and + * `ensureWorkspace` / `setWorkspaceId` / first-turn system-prompt + * construction are NOT skipped) and BEFORE the first message append (so the + * append's auto-title does not overwrite it). Omit to keep the auto-derived + * title. The caller is responsible for trimming/validation. + */ + readonly title?: string; } export type StartTurnResult = @@ -396,6 +406,8 @@ export interface SessionOrchestrator { systemPrompt?: string; /** Images attached to this turn — see {@link StartTurnInput.images}. */ images?: readonly ImageInput[]; + /** Conversation tab title — see {@link StartTurnInput.title}. */ + title?: string; }): Promise<void>; } @@ -574,6 +586,7 @@ export function createSessionOrchestrator( workspaceId: string, systemPromptOverride: string | undefined, images: readonly ImageInput[] | undefined, + title: string | undefined, ): void { const turnId = generateTurnId(); const promptStartedAt = deps.now?.() ?? Date.now(); @@ -593,14 +606,34 @@ export function createSessionOrchestrator( // The newness flag is also reused to decide whether to construct // (first turn) or get (subsequent turn) the system prompt — see the // providerOpts assembly below. + // + // An explicit `title` (e.g. the CLI `--title` flag) is persisted HERE, + // AFTER the workspace setup resolves — deliberately NOT before the turn. + // Setting it earlier (e.g. in the HTTP route) would pre-create the meta + // row, make `meta !== null`, and fool this newness check into skipping + // `ensureWorkspace` / `setWorkspaceId` / first-turn system-prompt + // construction. By deferring it to here, the title lands after the + // workspace is assigned but BEFORE the first message append (so the + // append's auto-title sees a non-"Untitled" title and preserves it). const workspaceSetupPromise = (async (): Promise<boolean> => { const meta = await deps.conversationStore.getConversationMeta(conversationId); if (meta === null) { await deps.conversationStore.ensureWorkspace(workspaceId); await deps.conversationStore.setWorkspaceId(conversationId, workspaceId); - return true; } - return false; + if (title !== undefined) { + // Best-effort: a title-set failure must NOT break the turn (the + // workspace setup above already succeeded). Log and continue — the + // append's auto-derived title applies instead. + try { + await deps.conversationStore.setConversationTitle(conversationId, title); + } catch (err) { + deps.logger?.child({ conversationId }).warn("orchestrator: title set failure", { + error: err instanceof Error ? err.message : String(err), + }); + } + } + return meta === null; })(); // ALWAYS resolve the effective cwd through getEffectiveCwd, passing the @@ -1138,6 +1171,7 @@ export function createSessionOrchestrator( workspaceId, systemPrompt, images, + title, }) { if (activeTurns.has(conversationId)) { return { started: false, reason: "already-active" }; @@ -1152,6 +1186,7 @@ export function createSessionOrchestrator( workspaceId ?? "default", systemPrompt, images, + title, ); const turn = activeTurns.get(conversationId); const turnId = turn !== undefined ? turn.turnId : ""; @@ -1270,6 +1305,7 @@ export function createSessionOrchestrator( workspaceId, systemPrompt, images, + title, }) { const turnInput: StartTurnInput = { conversationId, @@ -1281,6 +1317,7 @@ export function createSessionOrchestrator( ...(workspaceId !== undefined ? { workspaceId } : {}), ...(systemPrompt !== undefined ? { systemPrompt } : {}), ...(images !== undefined ? { images } : {}), + ...(title !== undefined ? { title } : {}), }; const result = orchestrator.startTurn(turnInput); if (!result.started) { diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts index 015b385..f583954 100644 --- a/packages/transport-contract/src/index.ts +++ b/packages/transport-contract/src/index.ts @@ -123,19 +123,19 @@ export interface ChatRequest { 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 }`. + * A human-readable title for the conversation tab — persisted at creation + * time, after the new-conversation workspace setup resolves (so workspace + * assignment and first-turn system-prompt construction are not skipped) and + * before the first message append (so the append's auto-derived title does + * not overwrite it). The tab shows it 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`). + * dedicated `PUT /conversations/:id/title` endpoint but is atomic with the + * turn (no second round-trip from the client). */ readonly title?: string; } diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts index 9b1480d..0876cdb 100644 --- a/packages/transport-http/src/app.test.ts +++ b/packages/transport-http/src/app.test.ts @@ -800,17 +800,11 @@ describe("POST /chat", () => { 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 }); - }, - }; + it("forwards the title to the orchestrator", async () => { + const cap = createCapturingOrchestrator(); const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), + conversationStore: createFakeConversationStore(), + orchestrator: cap, credentialStore: createFakeCredentialStore([]), }); @@ -821,20 +815,15 @@ describe("POST /chat", () => { }); expect(res.status).toBe(200); - expect(calls).toEqual([{ conversationId: "conv1", title: "My Task" }]); + expect(cap.received).toBeDefined(); + expect(cap.received?.title).toBe("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 }); - }, - }; + it("forwards a trimmed title to the orchestrator", async () => { + const cap = createCapturingOrchestrator(); const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), + conversationStore: createFakeConversationStore(), + orchestrator: cap, credentialStore: createFakeCredentialStore([]), }); @@ -845,20 +834,14 @@ describe("POST /chat", () => { }); expect(res.status).toBe(200); - expect(calls).toEqual([{ conversationId: "conv1", title: "spaced" }]); + expect(cap.received?.title).toBe("spaced"); }); - it("does not call setConversationTitle when title is omitted", async () => { - let setTitleCalled = false; - const store: ConversationStore = { - ...createFakeConversationStore(), - async setConversationTitle() { - setTitleCalled = true; - }, - }; + it("does not forward a title when omitted", async () => { + const cap = createCapturingOrchestrator(); const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), + conversationStore: createFakeConversationStore(), + orchestrator: cap, credentialStore: createFakeCredentialStore([]), }); @@ -869,20 +852,14 @@ describe("POST /chat", () => { }); expect(res.status).toBe(200); - expect(setTitleCalled).toBe(false); + expect(cap.received?.title).toBeUndefined(); }); - it("does not call setConversationTitle for a whitespace-only title", async () => { - let setTitleCalled = false; - const store: ConversationStore = { - ...createFakeConversationStore(), - async setConversationTitle() { - setTitleCalled = true; - }, - }; + it("does not forward a title for a whitespace-only title", async () => { + const cap = createCapturingOrchestrator(); const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), + conversationStore: createFakeConversationStore(), + orchestrator: cap, credentialStore: createFakeCredentialStore([]), }); @@ -893,19 +870,12 @@ describe("POST /chat", () => { }); expect(res.status).toBe(200); - expect(setTitleCalled).toBe(false); + expect(cap.received?.title).toBeUndefined(); }); 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, + conversationStore: createFakeConversationStore(), orchestrator: createFakeOrchestrator([]), credentialStore: createFakeCredentialStore([]), }); @@ -919,21 +889,19 @@ describe("POST /chat", () => { 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 () => { + it("does not call setConversationTitle itself (the orchestrator owns it)", async () => { + let setTitleCalled = false; const store: ConversationStore = { ...createFakeConversationStore(), async setConversationTitle() { - throw new Error("store unavailable"); + setTitleCalled = true; }, }; const app = createApp({ conversationStore: store, - orchestrator: createFakeOrchestrator([ - { type: "done", conversationId: "conv1", turnId: "t1", reason: "stop" }, - ]), + orchestrator: createFakeOrchestrator([]), credentialStore: createFakeCredentialStore([]), }); @@ -944,8 +912,10 @@ describe("POST /chat", () => { }); expect(res.status).toBe(200); - const text = await res.text(); - expect(text.trim().split("\n")).toHaveLength(1); + // 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); }); }); diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts index 03b6ec2..6e0748b 100644 --- a/packages/transport-http/src/app.ts +++ b/packages/transport-http/src/app.ts @@ -469,22 +469,6 @@ 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; @@ -534,6 +518,7 @@ export function createApp(opts: CreateServerOptions): Hono { ...(reasoningEffort !== undefined ? { reasoningEffort } : {}), ...(workspaceId !== undefined ? { workspaceId } : {}), ...(images !== undefined ? { images } : {}), + ...(title !== undefined ? { title } : {}), }; opts.orchestrator diff --git a/packages/transport-http/src/logic.ts b/packages/transport-http/src/logic.ts index 5cf96cc..c703049 100644 --- a/packages/transport-http/src/logic.ts +++ b/packages/transport-http/src/logic.ts @@ -59,8 +59,10 @@ export interface ChatCommand { * 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. + * 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; /** |
