From 038eff6a73547644943a0410f579933aa76b81f1 Mon Sep 17 00:00:00 2001 From: "xudong.w" Date: Sun, 5 Apr 2026 23:51:03 +0800 Subject: [PATCH 1/2] Fix desktop codex chat resume compatibility --- src/adapters/codex-app-server.test.ts | 120 ++++++++++++++++++++ src/adapters/codex-app-server.ts | 109 ++++++++++++++---- src/adapters/conductor-registry.test.ts | 144 ++++++++++++++++++++++++ src/adapters/conductor-registry.ts | 22 +++- src/index.test.ts | 2 + src/index.ts | 6 +- src/types.ts | 2 + 7 files changed, 380 insertions(+), 25 deletions(-) diff --git a/src/adapters/codex-app-server.test.ts b/src/adapters/codex-app-server.test.ts index b24bd34..f487a05 100644 --- a/src/adapters/codex-app-server.test.ts +++ b/src/adapters/codex-app-server.test.ts @@ -18,6 +18,14 @@ vi.mock("node:child_process", async () => { import { buildCodexChildEnv, CodexAppServerAdapter } from "./codex-app-server.js"; +const DEFAULT_CODEX_FEATURE_FLAGS = { + "features.enable_request_compression": true, + "features.collaboration_modes": true, + "features.personality": true, + "features.request_rule": true, + "features.fast_mode": true, +} as const; + function createFakeChild(options?: { deferWriteCallback?: boolean }): { child: ChildProcessWithoutNullStreams; stdinWrite: ReturnType; @@ -63,6 +71,31 @@ describe("buildCodexChildEnv", () => { }); describe("CodexAppServerAdapter", () => { + it("sends initialized after initialize succeeds", async () => { + const { child, stdinWrite } = createFakeChild(); + spawnMock.mockReturnValue(child); + + const adapter = new CodexAppServerAdapter("/tmp/codex"); + const startPromise = adapter.start(); + + expect(stdinWrite).toHaveBeenCalledTimes(1); + const initializePayload = JSON.parse(stdinWrite.mock.calls[0]?.[0] ?? "{}") as { + id?: string; + method?: string; + }; + + expect(initializePayload.method).toBe("initialize"); + (child.stdout as PassThrough).write(`${JSON.stringify({ jsonrpc: "2.0", id: initializePayload.id, result: {} })}\n`); + + await expect(startPromise).resolves.toBeUndefined(); + + expect(stdinWrite).toHaveBeenCalledTimes(2); + expect(JSON.parse(stdinWrite.mock.calls[1]?.[0] ?? "{}")).toEqual({ + jsonrpc: "2.0", + method: "initialized", + }); + }); + it("rejects start when the codex child emits an error", async () => { const { child } = createFakeChild({ deferWriteCallback: true }); spawnMock.mockReturnValue(child); @@ -99,8 +132,11 @@ describe("CodexAppServerAdapter", () => { threadId: "thread-1", cwd: "/tmp/workspace", model: "gpt-5.4", + serviceTier: null, approvalPolicy: "never", sandbox: "danger-full-access", + persistExtendedHistory: true, + personality: null, }); }); @@ -119,6 +155,90 @@ describe("CodexAppServerAdapter", () => { }), ).rejects.toBe(error); }); + + it("persists extended history when starting threads", async () => { + const adapter = new CodexAppServerAdapter("/tmp/codex"); + const request = vi.fn().mockResolvedValue({ thread: { id: "thread-9" } }); + + (adapter as unknown as { request: typeof request }).request = request; + + await expect( + adapter.startThread({ + cwd: "/tmp/workspace", + model: "gpt-5.4", + }), + ).resolves.toBe("thread-9"); + + expect(request).toHaveBeenCalledWith("thread/start", { + cwd: "/tmp/workspace", + model: "gpt-5.4", + experimentalRawEvents: false, + serviceTier: null, + approvalPolicy: "never", + sandbox: "danger-full-access", + persistExtendedHistory: true, + personality: null, + config: DEFAULT_CODEX_FEATURE_FLAGS, + }); + }); + + it("omits nullish resume params instead of sending JSON nulls", async () => { + const adapter = new CodexAppServerAdapter("/tmp/codex"); + const request = vi.fn().mockResolvedValue({}); + + (adapter as unknown as { request: typeof request }).request = request; + + await expect( + adapter.resumeThread({ + threadId: "thread-1", + cwd: null, + model: null, + }), + ).resolves.toBeUndefined(); + + expect(request).toHaveBeenCalledWith("thread/resume", { + threadId: "thread-1", + serviceTier: null, + approvalPolicy: "never", + sandbox: "danger-full-access", + persistExtendedHistory: true, + personality: null, + }); + }); + + it("omits nullish turn params instead of sending JSON nulls", async () => { + const adapter = new CodexAppServerAdapter("/tmp/codex"); + const request = vi.fn().mockResolvedValue({ turn: { id: "turn-1" } }); + + (adapter as unknown as { request: typeof request }).request = request; + + await expect( + adapter.startTurn({ + threadId: "thread-1", + input: "ping", + cwd: null, + model: null, + effort: null, + }), + ).resolves.toEqual({ turnId: "turn-1" }); + + expect(request).toHaveBeenCalledWith("turn/start", { + threadId: "thread-1", + input: [{ type: "text", text: "ping", text_elements: [] }], + serviceTier: null, + summary: "none", + sandboxPolicy: { type: "dangerFullAccess" }, + personality: null, + collaborationMode: { + mode: "default", + settings: { + model: null, + reasoning_effort: null, + developer_instructions: null, + }, + }, + }); + }); }); describe("CodexAppServerAdapter.interruptTurn", () => { diff --git a/src/adapters/codex-app-server.ts b/src/adapters/codex-app-server.ts index ef14a0a..9e3966a 100644 --- a/src/adapters/codex-app-server.ts +++ b/src/adapters/codex-app-server.ts @@ -50,6 +50,39 @@ interface InterruptTurnOptions { turnId: string; } +const DEFAULT_CODEX_FEATURE_FLAGS = { + "features.enable_request_compression": true, + "features.collaboration_modes": true, + "features.personality": true, + "features.request_rule": true, + "features.fast_mode": true, +} as const; + +function withOptionalFields( + base: Record, + optional: Record, +): Record { + return { + ...base, + ...Object.fromEntries(Object.entries(optional).filter(([, value]) => value !== null && value !== undefined)), + }; +} + +function buildTextInput(text: string): Array<{ type: "text"; text: string; text_elements: [] }> { + return [{ type: "text", text, text_elements: [] }]; +} + +function buildCollaborationMode(model?: string | null, effort?: string | null): Record { + return { + mode: "default", + settings: { + model: model ?? null, + reasoning_effort: effort ?? null, + developer_instructions: null, + }, + }; +} + const BRIDGE_ONLY_ENV_KEYS = [ "BRIDGE_DB_PATH", "BRIDGE_ENV_PATH", @@ -141,6 +174,7 @@ export class CodexAppServerAdapter extends EventEmitter { experimentalApi: true, }, }); + await this.writeJsonRpcMessage({ jsonrpc: "2.0", method: "initialized" }); } catch (error) { if (this.process === child) { child.kill("SIGTERM"); @@ -158,12 +192,24 @@ export class CodexAppServerAdapter extends EventEmitter { } async startThread(options: StartThreadOptions): Promise { - const result = (await this.request("thread/start", { - cwd: options.cwd, - model: options.model, - approvalPolicy: "never", - sandbox: "danger-full-access", - })) as { thread?: { id?: string } }; + const result = (await this.request( + "thread/start", + withOptionalFields( + { + experimentalRawEvents: false, + persistExtendedHistory: true, + serviceTier: null, + sandbox: "danger-full-access", + approvalPolicy: "never", + personality: null, + config: DEFAULT_CODEX_FEATURE_FLAGS, + }, + { + cwd: options.cwd, + model: options.model, + }, + ), + )) as { thread?: { id?: string } }; const threadId = result?.thread?.id; if (!threadId) { throw new Error(`thread/start did not return thread.id: ${JSON.stringify(result)}`); @@ -173,13 +219,23 @@ export class CodexAppServerAdapter extends EventEmitter { async resumeThread(options: ResumeThreadOptions): Promise { try { - await this.request("thread/resume", { - threadId: options.threadId, - cwd: options.cwd ?? null, - model: options.model ?? null, - approvalPolicy: "never", - sandbox: "danger-full-access", - }); + await this.request( + "thread/resume", + withOptionalFields( + { + threadId: options.threadId, + persistExtendedHistory: true, + serviceTier: null, + sandbox: "danger-full-access", + approvalPolicy: "never", + personality: null, + }, + { + cwd: options.cwd, + model: options.model, + }, + ), + ); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (options.allowMissingRollout && message.includes("no rollout found for thread id")) { @@ -227,13 +283,24 @@ export class CodexAppServerAdapter extends EventEmitter { } async startTurn(options: StartTurnOptions): Promise<{ turnId: string }> { - const response = (await this.request("turn/start", { - threadId: options.threadId, - cwd: options.cwd ?? null, - model: options.model ?? null, - effort: options.effort ?? null, - input: [{ type: "text", text: options.input }], - })) as { turn?: { id?: string } }; + const response = (await this.request( + "turn/start", + withOptionalFields( + { + threadId: options.threadId, + input: buildTextInput(options.input), + serviceTier: null, + summary: "none", + sandboxPolicy: { type: "dangerFullAccess" }, + personality: null, + collaborationMode: buildCollaborationMode(options.model, options.effort), + }, + { + model: options.model, + effort: options.effort, + }, + ), + )) as { turn?: { id?: string } }; const turnId = response?.turn?.id; if (!turnId) { @@ -246,7 +313,7 @@ export class CodexAppServerAdapter extends EventEmitter { await this.request("turn/steer", { threadId: options.threadId, expectedTurnId: options.expectedTurnId, - input: [{ type: "text", text: options.input }], + input: buildTextInput(options.input), }); } diff --git a/src/adapters/conductor-registry.test.ts b/src/adapters/conductor-registry.test.ts index 79b3356..67df410 100644 --- a/src/adapters/conductor-registry.test.ts +++ b/src/adapters/conductor-registry.test.ts @@ -36,8 +36,17 @@ function createRegistryFixture(): { id TEXT PRIMARY KEY, workspace_id TEXT, status TEXT, + agent_type TEXT, + model TEXT, + permission_mode TEXT, title TEXT, + claude_session_id TEXT, last_user_message_at TEXT, + codex_thinking_level TEXT, + thinking_enabled INTEGER DEFAULT 1, + agent_personality TEXT, + created_at TEXT, + updated_at TEXT, is_hidden INTEGER DEFAULT 0 ); @@ -464,4 +473,139 @@ describe("ConductorRegistryAdapter", () => { const other = adapter.createWorkspace("repo-1", "demo/berlin"); expect(other.directoryName).toBe("berlin-v1"); }); + + it("defaults new sessions to desktop-compatible thinking fields", () => { + const { adapter, db } = createRegistryFixture(); + + db.prepare( + ` + INSERT INTO workspaces ( + id, + repository_id, + directory_name, + branch, + active_session_id, + created_at, + updated_at, + state + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?); + `, + ).run( + "workspace-1", + "repo-1", + "telegram-bridge", + "master", + null, + "2026-03-29T04:00:00.000Z", + "2026-03-29T04:00:00.000Z", + "ready", + ); + + const session = adapter.createSession("workspace-1", "thread-1", { + agentType: "codex", + model: "gpt-5.4", + permissionMode: "default", + title: "Fix freeze", + }); + + const row = db + .prepare( + ` + SELECT + claude_session_id as claudeSessionId, + codex_thinking_level as codexThinkingLevel, + thinking_enabled as thinkingEnabled, + agent_personality as agentPersonality + FROM sessions + WHERE id = ? + `, + ) + .get(session.id) as + | { + claudeSessionId: string; + codexThinkingLevel: string | null; + thinkingEnabled: number; + agentPersonality: string | null; + } + | undefined; + const workspaceRow = db + .prepare( + ` + SELECT active_session_id as activeSessionId + FROM workspaces + WHERE id = ? + `, + ) + .get("workspace-1") as { activeSessionId: string | null } | undefined; + + expect(row).toEqual({ + claudeSessionId: "thread-1", + codexThinkingLevel: "xhigh", + thinkingEnabled: 1, + agentPersonality: "none", + }); + expect(workspaceRow?.activeSessionId).toBe(session.id); + }); + + it("stores thinking-disabled sessions without leaking null personality", () => { + const { adapter, db } = createRegistryFixture(); + + db.prepare( + ` + INSERT INTO workspaces ( + id, + repository_id, + directory_name, + branch, + active_session_id, + created_at, + updated_at, + state + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?); + `, + ).run( + "workspace-1", + "repo-1", + "telegram-bridge", + "master", + null, + "2026-03-29T04:00:00.000Z", + "2026-03-29T04:00:00.000Z", + "ready", + ); + + const session = adapter.createSession("workspace-1", "thread-2", { + agentType: "codex", + model: "gpt-5.4", + permissionMode: "default", + title: "No thinking", + codexThinkingLevel: "none", + agentPersonality: "none", + }); + + const row = db + .prepare( + ` + SELECT + codex_thinking_level as codexThinkingLevel, + thinking_enabled as thinkingEnabled, + agent_personality as agentPersonality + FROM sessions + WHERE id = ? + `, + ) + .get(session.id) as + | { + codexThinkingLevel: string | null; + thinkingEnabled: number; + agentPersonality: string | null; + } + | undefined; + + expect(row).toEqual({ + codexThinkingLevel: null, + thinkingEnabled: 0, + agentPersonality: "none", + }); + }); }); diff --git a/src/adapters/conductor-registry.ts b/src/adapters/conductor-registry.ts index 68bfd99..228f852 100644 --- a/src/adapters/conductor-registry.ts +++ b/src/adapters/conductor-registry.ts @@ -645,6 +645,10 @@ export class ConductorRegistryAdapter { createSession(workspaceId: string, threadId: string, seed: SessionSeed): ConductorSessionRef { const sessionId = randomUUID(); + const normalizedThinkingLevel = seed.codexThinkingLevel?.trim() || "xhigh"; + const thinkingEnabled = normalizedThinkingLevel === "none" ? 0 : 1; + const storedThinkingLevel = thinkingEnabled === 0 ? null : normalizedThinkingLevel; + const agentPersonality = seed.agentPersonality?.trim() || "none"; const tx = this.db.transaction(() => { this.db @@ -660,13 +664,27 @@ export class ConductorRegistryAdapter { permission_mode, title, last_user_message_at, + codex_thinking_level, + thinking_enabled, + agent_personality, created_at, updated_at ) - VALUES (?, 'idle', ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'), datetime('now')) + VALUES (?, 'idle', ?, ?, ?, ?, ?, ?, datetime('now'), ?, ?, ?, datetime('now'), datetime('now')) `, ) - .run(sessionId, threadId, workspaceId, seed.agentType ?? "codex", seed.model, seed.permissionMode, seed.title); + .run( + sessionId, + threadId, + workspaceId, + seed.agentType ?? "codex", + seed.model, + seed.permissionMode, + seed.title, + storedThinkingLevel, + thinkingEnabled, + agentPersonality, + ); this.db .prepare( diff --git a/src/index.test.ts b/src/index.test.ts index 645520f..343bd62 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1880,6 +1880,8 @@ describe("TelegramConductorBridge", () => { "thread-1", expect.objectContaining({ model: "gpt-5.3-codex", + codexThinkingLevel: "high", + agentPersonality: "none", }), ); expect(submitPrompt).toHaveBeenCalledWith( diff --git a/src/index.ts b/src/index.ts index fd7329b..f03e6d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1689,8 +1689,8 @@ export class TelegramConductorBridge { const defaults = this.registry.getSessionDefaults(workspaceId); const model = context.composeModel?.trim() ? context.composeModel.trim() : defaults.model; const reasoningEffort = - defaults.agentType === "codex" && context.composeReasoningEffort?.trim() - ? context.composeReasoningEffort.trim() + defaults.agentType === "codex" + ? (context.composeReasoningEffort?.trim() || this.registry.getCodexReasoningEffortForNewSession(workspaceId)) : null; const workspacePath = this.registry.resolveWorkspacePath(workspaceId); const title = sanitizeSessionTitle(text); @@ -1712,6 +1712,8 @@ export class TelegramConductorBridge { model, permissionMode: defaults.permissionMode, title, + codexThinkingLevel: reasoningEffort, + agentPersonality: "none", }); this.sessionIdByThreadId.set(threadId, session.id); diff --git a/src/types.ts b/src/types.ts index 519e9be..7e7c119 100644 --- a/src/types.ts +++ b/src/types.ts @@ -109,6 +109,8 @@ export interface SessionSeed { permissionMode: string; title: string; agentType?: SupportedAgentType; + codexThinkingLevel?: string | null; + agentPersonality?: string | null; } export interface SessionDefaults { From fbf778709c073f23f44d1f4bb986162235b35ca6 Mon Sep 17 00:00:00 2001 From: "xudong.w" Date: Sun, 5 Apr 2026 23:55:22 +0800 Subject: [PATCH 2/2] Format CI-touched files --- src/adapters/codex-app-server.test.ts | 4 +++- src/adapters/codex-app-server.ts | 5 +---- src/index.ts | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/adapters/codex-app-server.test.ts b/src/adapters/codex-app-server.test.ts index f487a05..55a79f6 100644 --- a/src/adapters/codex-app-server.test.ts +++ b/src/adapters/codex-app-server.test.ts @@ -85,7 +85,9 @@ describe("CodexAppServerAdapter", () => { }; expect(initializePayload.method).toBe("initialize"); - (child.stdout as PassThrough).write(`${JSON.stringify({ jsonrpc: "2.0", id: initializePayload.id, result: {} })}\n`); + (child.stdout as PassThrough).write( + `${JSON.stringify({ jsonrpc: "2.0", id: initializePayload.id, result: {} })}\n`, + ); await expect(startPromise).resolves.toBeUndefined(); diff --git a/src/adapters/codex-app-server.ts b/src/adapters/codex-app-server.ts index 9e3966a..45a6b35 100644 --- a/src/adapters/codex-app-server.ts +++ b/src/adapters/codex-app-server.ts @@ -58,10 +58,7 @@ const DEFAULT_CODEX_FEATURE_FLAGS = { "features.fast_mode": true, } as const; -function withOptionalFields( - base: Record, - optional: Record, -): Record { +function withOptionalFields(base: Record, optional: Record): Record { return { ...base, ...Object.fromEntries(Object.entries(optional).filter(([, value]) => value !== null && value !== undefined)), diff --git a/src/index.ts b/src/index.ts index f03e6d9..5664395 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1690,7 +1690,7 @@ export class TelegramConductorBridge { const model = context.composeModel?.trim() ? context.composeModel.trim() : defaults.model; const reasoningEffort = defaults.agentType === "codex" - ? (context.composeReasoningEffort?.trim() || this.registry.getCodexReasoningEffortForNewSession(workspaceId)) + ? context.composeReasoningEffort?.trim() || this.registry.getCodexReasoningEffortForNewSession(workspaceId) : null; const workspacePath = this.registry.resolveWorkspacePath(workspaceId); const title = sanitizeSessionTitle(text);