Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/TitleGenerator.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { CodexAppServerClient } from "./CodexAppServerClient";
import type { Turn } from "./app-server/v2";
import type { JsonValue } from "./app-server/serde_json/JsonValue";

// Use cheap model to generate a title
const TITLE_MODEL = "gpt-5.6-luna";
Expand Down Expand Up @@ -133,9 +134,11 @@ export class TitleGenerator {

// Ephemeral thread: not persisted to disk, not visible in thread list,
// but goes through the same auth layer as the main session.
const config = await this.mcpServersDisabledConfig();
const { thread: epThread } = await this.client.threadStart({
cwd: this.cwd,
ephemeral: true,
...(config !== null && {config}),
});

const turnResult = await this.client.runTurn({
Expand Down Expand Up @@ -175,6 +178,29 @@ export class TitleGenerator {
await this.waitForRenameEcho(RENAME_ECHO_TIMEOUT_MS);
}

/**
* A thread config that turns off every MCP server the Codex config defines for this cwd.
*
* The title turn uses no tools, but a thread starts all configured MCP servers.
* An empty `mcp_servers` table changes nothing, because Codex merges the thread config
* onto the loaded config, so each server is disabled by name. Only names the config defines
* can be disabled: an unknown name makes `thread/start` fail. `null` when no server is
* configured or the config cannot be read.
*/
private async mcpServersDisabledConfig(): Promise<{ [key: string]: JsonValue } | null> {
let servers: JsonValue | undefined;
try {
const response = await this.client.configRead({includeLayers: false, cwd: this.cwd});
servers = response?.config?.["mcp_servers"];
} catch {
return null;
}
if (!isJsonObject(servers)) return null;
const names = Object.keys(servers);
if (names.length === 0) return null;
return {"mcp_servers": Object.fromEntries(names.map(name => [name, {enabled: false}]))};
}

private async waitForRenameEcho(timeoutMs: number): Promise<void> {
await new Promise<void>(resolve => {
this.renameEchoResolve = resolve;
Expand All @@ -189,6 +215,10 @@ export class TitleGenerator {
}
}

function isJsonObject(value: JsonValue | undefined): value is { [key: string]: JsonValue } {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function extractTitle(turn: Turn): string | null {
for (const item of turn.items) {
if (item.type !== "agentMessage") continue;
Expand Down
46 changes: 46 additions & 0 deletions src/__tests__/TitleGenerator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,49 @@ describe("TitleGenerator prompt", () => {
expect(text.endsWith(`User's first message:\n${"a".repeat(3_999)}`)).toBe(true);
});
});

describe("TitleGenerator MCP servers", () => {
async function startedThreadParams(configRead: unknown) {
const threadStart = vi.fn().mockResolvedValue({thread: {id: "ephemeral"}});
const generator = createGenerator({
configRead,
threadStart,
runTurn: vi.fn().mockResolvedValue({turn: {items: []}}),
} as unknown as Partial<CodexAppServerClient>);

generator.onTurnCompleted("hello");
await generator.waitForIdle(1_000);

expect(threadStart).toHaveBeenCalledTimes(1);
return threadStart.mock.calls[0]![0];
}

it("starts the title thread with every configured MCP server disabled", async () => {
const configRead = vi.fn().mockResolvedValue({
config: {mcp_servers: {docs: {command: "docs-mcp"}, "kirocrew-dashboard": {url: "http://127.0.0.1:1/mcp"}}},
origins: {},
layers: null,
});

const params = await startedThreadParams(configRead);

expect(configRead).toHaveBeenCalledWith({includeLayers: false, cwd: "/test/cwd"});
expect(params).toEqual({
cwd: "/test/cwd",
ephemeral: true,
config: {mcp_servers: {docs: {enabled: false}, "kirocrew-dashboard": {enabled: false}}},
});
});

it("passes no config when no MCP server is configured", async () => {
const params = await startedThreadParams(vi.fn().mockResolvedValue({config: {}, origins: {}, layers: null}));

expect(params).toEqual({cwd: "/test/cwd", ephemeral: true});
});

it("still generates the title when the config cannot be read", async () => {
const params = await startedThreadParams(vi.fn().mockRejectedValue(new Error("config/read failed")));

expect(params).toEqual({cwd: "/test/cwd", ephemeral: true});
});
});