From c76015ad8e39927da4185afc53617007ad021bf9 Mon Sep 17 00:00:00 2001 From: Wesley Jinks Date: Tue, 13 Oct 2026 19:45:12 -0600 Subject: [PATCH] fix: release the title-generation thread after the title turn The ephemeral thread that generates a session title was never unsubscribed, and session/close unsubscribes only the session's own thread. Codex keeps a subscribed thread loaded, so the title thread and the MCP servers started for it stayed until codex-acp exited. Unsubscribe the ephemeral thread once its turn has settled, whether the turn succeeded or failed. Fixes #628 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017izEynabPeSUBT573AtvaZ --- src/TitleGenerator.ts | 27 +++++--- .../CodexACPAgent/session-fork-prompt.test.ts | 4 +- src/__tests__/TitleGenerator.test.ts | 61 +++++++++++++++++++ 3 files changed, 81 insertions(+), 11 deletions(-) diff --git a/src/TitleGenerator.ts b/src/TitleGenerator.ts index 770a6081..a01ee9fa 100644 --- a/src/TitleGenerator.ts +++ b/src/TitleGenerator.ts @@ -138,16 +138,23 @@ export class TitleGenerator { ephemeral: true, }); - const turnResult = await this.client.runTurn({ - threadId: epThread.id, - input: [{ - type: "text", - text: `${SYSTEM_PROMPT}\n\nUser's first message:\n${titleSource(userPromptText)}`, - text_elements: [], - }], - outputSchema: TITLE_OUTPUT_SCHEMA, - model: TITLE_MODEL, - }); + let turnResult; + try { + turnResult = await this.client.runTurn({ + threadId: epThread.id, + input: [{ + type: "text", + text: `${SYSTEM_PROMPT}\n\nUser's first message:\n${titleSource(userPromptText)}`, + text_elements: [], + }], + outputSchema: TITLE_OUTPUT_SCHEMA, + model: TITLE_MODEL, + }); + } finally { + // Codex keeps a subscribed thread loaded, with its MCP servers, and session/close + // releases only the main thread. + await this.client.threadUnsubscribe({threadId: epThread.id}).catch(() => {}); + } const title = extractTitle(turnResult.turn); if (!title) return; diff --git a/src/__tests__/CodexACPAgent/session-fork-prompt.test.ts b/src/__tests__/CodexACPAgent/session-fork-prompt.test.ts index 160f9baa..f195c4cd 100644 --- a/src/__tests__/CodexACPAgent/session-fork-prompt.test.ts +++ b/src/__tests__/CodexACPAgent/session-fork-prompt.test.ts @@ -79,7 +79,9 @@ describe("a prompt after session/fork", () => { expect(forkId).toBe("fork-id"); await expect(promptWithin(fixture, forkId)).resolves.toMatchObject({stopReason: "end_turn"}); await expect(promptWithin(fixture, sourceId)).resolves.toMatchObject({stopReason: "end_turn"}); - expect(requestsOf(fixture.current(), "thread/unsubscribe")).toEqual([]); + // Only the ephemeral title thread may be released; the fork and its source stay subscribed. + const unsubscribed = requestsOf(fixture.current(), "thread/unsubscribe") as {threadId: string}[]; + expect(unsubscribed.filter(({threadId}) => threadId !== "title-thread")).toEqual([]); expect(subscribed).toContain(forkId); expect(subscribed).toContain(sourceId); fixture.agent.sessionIndex.dispose(); diff --git a/src/__tests__/TitleGenerator.test.ts b/src/__tests__/TitleGenerator.test.ts index 70aaaeed..9294f2e4 100644 --- a/src/__tests__/TitleGenerator.test.ts +++ b/src/__tests__/TitleGenerator.test.ts @@ -21,6 +21,7 @@ describe("TitleGenerator.waitForIdle", () => { threadStart: vi.fn().mockResolvedValue({thread: {id: "ephemeral"}}), runTurn: vi.fn().mockReturnValue(turn.promise), threadSetName, + threadUnsubscribe: vi.fn().mockResolvedValue({}), } as unknown as Partial); generator.onTurnCompleted("hello"); @@ -51,6 +52,7 @@ describe("TitleGenerator.waitForIdle", () => { threadStart: vi.fn().mockResolvedValue({thread: {id: "ephemeral"}}), runTurn: vi.fn().mockReturnValue(new Promise(() => {})), threadSetName: vi.fn(), + threadUnsubscribe: vi.fn().mockResolvedValue({}), } as unknown as Partial); generator.onTurnCompleted("hello"); @@ -63,6 +65,7 @@ describe("TitleGenerator.waitForIdle", () => { threadStart: vi.fn().mockRejectedValue(new Error("no ephemeral threads")), runTurn: vi.fn(), threadSetName: vi.fn(), + threadUnsubscribe: vi.fn().mockResolvedValue({}), } as unknown as Partial); generator.onTurnCompleted("hello"); @@ -77,6 +80,7 @@ describe("TitleGenerator prompt", () => { const generator = createGenerator({ threadStart: vi.fn().mockResolvedValue({thread: {id: "ephemeral"}}), runTurn, + threadUnsubscribe: vi.fn().mockResolvedValue({}), } as unknown as Partial); // The cut falls between the two halves of the emoji. @@ -87,3 +91,60 @@ describe("TitleGenerator prompt", () => { expect(text.endsWith(`User's first message:\n${"a".repeat(3_999)}`)).toBe(true); }); }); + +describe("TitleGenerator ephemeral thread", () => { + const titleTurn = {turn: {items: [{type: "agentMessage", text: '{"title":"A short title"}'}]}}; + + function createClient(overrides: Record = {}) { + return { + threadStart: vi.fn().mockResolvedValue({thread: {id: "ephemeral"}}), + runTurn: vi.fn().mockResolvedValue(titleTurn), + threadSetName: vi.fn().mockResolvedValue({}), + threadUnsubscribe: vi.fn().mockResolvedValue({}), + ...overrides, + }; + } + + async function generate(client: ReturnType) { + const generator = createGenerator(client as unknown as Partial); + generator.onTurnCompleted("hello"); + // The rename echo never arrives in these tests; the thread must be released before that wait. + await generator.waitForIdle(20); + } + + it("unsubscribes the ephemeral thread after the title turn", async () => { + const client = createClient(); + + await generate(client); + + expect(client.threadUnsubscribe).toHaveBeenCalledTimes(1); + expect(client.threadUnsubscribe).toHaveBeenCalledWith({threadId: "ephemeral"}); + expect(client.threadUnsubscribe.mock.invocationCallOrder[0]) + .toBeLessThan(client.threadSetName.mock.invocationCallOrder[0]!); + }); + + it("unsubscribes the ephemeral thread when the title turn fails", async () => { + const client = createClient({runTurn: vi.fn().mockRejectedValue(new Error("model rejected"))}); + + await generate(client); + + expect(client.threadUnsubscribe).toHaveBeenCalledWith({threadId: "ephemeral"}); + expect(client.threadSetName).not.toHaveBeenCalled(); + }); + + it("still writes the title when the unsubscribe fails", async () => { + const client = createClient({threadUnsubscribe: vi.fn().mockRejectedValue(new Error("not loaded"))}); + + await generate(client); + + expect(client.threadSetName).toHaveBeenCalledWith({threadId: "thread-id", name: "A short title"}); + }); + + it("does not unsubscribe when no ephemeral thread was started", async () => { + const client = createClient({threadStart: vi.fn().mockRejectedValue(new Error("no ephemeral threads"))}); + + await generate(client); + + expect(client.threadUnsubscribe).not.toHaveBeenCalled(); + }); +});