summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-07-01 03:30:42 +0900
committerAdam Malczewski <[email protected]>2026-07-01 03:30:42 +0900
commit6bca0a8b65506239b0ce72d7f86ba96f825152b1 (patch)
tree7b6b3d0edeb2239acbaa605ba898abaf1711a12c
parent566c64033ad79538f9208fc3ef9477cd8a58f7da (diff)
parenta2e3bad36fd236835423e3e9124b8496b6d9c795 (diff)
downloaddispatch-6bca0a8b65506239b0ce72d7f86ba96f825152b1.tar.gz
dispatch-6bca0a8b65506239b0ce72d7f86ba96f825152b1.zip
Merge branch 'feature/summon-title' into predev
-rw-r--r--packages/session-orchestrator/src/orchestrator.test.ts188
-rw-r--r--packages/session-orchestrator/src/orchestrator.ts41
-rw-r--r--packages/transport-contract/src/index.ts22
-rw-r--r--packages/transport-http/src/app.test.ts90
-rw-r--r--packages/transport-http/src/app.ts17
-rw-r--r--packages/transport-http/src/logic.ts6
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;
/**