From d3ee659fb7cea72031cc24fe811bd3e7d310c2a2 Mon Sep 17 00:00:00 2001 From: deepcoldy Date: Tue, 28 Jul 2026 12:32:02 +0000 Subject: [PATCH 1/6] =?UTF-8?q?feat(trigger):=20async=20per-turn=20model?= =?UTF-8?q?=20+=20reasoningEffort=20=E9=80=8F=E4=BC=A0=EF=BC=88PR=20A?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拆自原 PR #638(superseded)的第一块,单一状态机、影响面最小。 - TriggerRequest.options 加 model?:string + reasoningEffort?:'low'|'medium'|'high'|'xhigh'(校验:model ≤200 字符、effort 枚举)。 - 仅新建会话生效:trigger-session 首 fork 前 stamp 到 session;sessionAgentConfig 的 agentFrozen 冻结(?? 保留 override);init 携带 reasoningEffort → worker → adapter。 - codex(纯/RPC 路径):buildArgs 注入 `-c model_reasoning_effort=`(xhigh→high)。 - codex-app(B 模式目标):buildArgs 透传 --model/--reasoning-effort 给 runner; codex-app-runner 注入 thread/start——model 走 ThreadStartParams 顶层 model、effort 走 config.model_reasoning_effort(codex 已按 0.145 生成类型确认该 schema,且 thread/start 每新会话一次、fold-in 不触发,契合 fresh-spawn-only 语义)。 影响面:只走 async trigger 专用路径;model/effort 仅 codex/codex-app 消费,其它 CLI 忽略 reasoningEffort(buildArgs 不解构即丢弃)。校验/透传单测;build 绿;trigger-api 27 测绿。 Co-Authored-By: Claude --- src/adapters/cli/codex-app.ts | 6 +++++- src/adapters/cli/codex.ts | 8 +++++++- src/adapters/cli/types.ts | 3 +++ src/codex-app-runner.ts | 15 ++++++++++++++- src/core/trigger-session.ts | 10 ++++++++++ src/core/worker-pool.ts | 4 +++- src/services/trigger-types.ts | 13 +++++++++++++ src/types.ts | 5 ++++- src/worker.ts | 1 + test/trigger-api.test.ts | 22 ++++++++++++++++++++++ 10 files changed, 82 insertions(+), 5 deletions(-) diff --git a/src/adapters/cli/codex-app.ts b/src/adapters/cli/codex-app.ts index a60e71668..d5722c921 100644 --- a/src/adapters/cli/codex-app.ts +++ b/src/adapters/cli/codex-app.ts @@ -43,7 +43,7 @@ export function createCodexAppAdapter(pathOverride?: string): CliAdapter { return [(cachedCodexBin ??= resolveCommand(rawCodexBin))]; }, - buildArgs({ sessionId, resume, resumeSessionId, workingDir, botName, botOpenId, locale }) { + buildArgs({ sessionId, resume, resumeSessionId, workingDir, botName, botOpenId, locale, model, reasoningEffort }) { const args = [ runnerPath(), '--session-id', sessionId, @@ -54,6 +54,10 @@ export function createCodexAppAdapter(pathOverride?: string): CliAdapter { pushOpt(args, '--bot-name', botName); pushOpt(args, '--bot-open-id', botOpenId); pushOpt(args, '--locale', locale); + // Per-turn overrides (async trigger API). The runner injects them into the + // app-server thread/start (model + config.model_reasoning_effort). + pushOpt(args, '--model', model && model.trim() ? model.trim() : undefined); + pushOpt(args, '--reasoning-effort', reasoningEffort); return args; }, diff --git a/src/adapters/cli/codex.ts b/src/adapters/cli/codex.ts index d5d1b63ec..b425d8174 100644 --- a/src/adapters/cli/codex.ts +++ b/src/adapters/cli/codex.ts @@ -154,7 +154,7 @@ export function createCodexAdapter(pathOverride?: string): CliAdapter { authPaths: ['~/.codex'], get resolvedBin(): string { return (cachedBin ??= resolveCommand(rawBin)); }, - buildArgs({ sessionId, resume, resumeSessionId, workingDir, model, disableCliBypass, readIsolation, remoteWsUrl, remoteThreadId }) { + buildArgs({ sessionId, resume, resumeSessionId, workingDir, model, reasoningEffort, disableCliBypass, readIsolation, remoteWsUrl, remoteThreadId }) { // Hybrid RPC input mode: attach this TUI to the botmux-owned app-server // thread. User input is delivered out-of-band via JSON-RPC (turn/start, // see codex-rpc-engine + worker), so the pane is a pure viewer — no paste @@ -202,6 +202,12 @@ export function createCodexAdapter(pathOverride?: string): CliAdapter { // Codex 接受 `--model ` / `-m `,写全名最稳,错的会在 codex 自己启动时报。 baseArgs.push('--model', model.trim()); } + if (reasoningEffort) { + // Per-turn reasoning effort → codex model_reasoning_effort(进程级 -c 覆盖, + // 不动用户全局 config)。codex 只接受 low/medium/high,botmux 'xhigh' 收敛到 'high'。 + const codexEffort = reasoningEffort === 'xhigh' ? 'high' : reasoningEffort; + baseArgs.push('-c', `model_reasoning_effort=${JSON.stringify(codexEffort)}`); + } // Codex app-server can keep its own cwd at $HOME; -C pins fresh agent roots. // NOTE: canonicalization of workingDir for the file sandbox is done ONCE in // worker.ts (only when sandboxRequested), so off-sandbox spawns keep the diff --git a/src/adapters/cli/types.ts b/src/adapters/cli/types.ts index b8701e3de..365572831 100644 --- a/src/adapters/cli/types.ts +++ b/src/adapters/cli/types.ts @@ -101,6 +101,9 @@ export interface CliAdapter { * `--model` flag (or equivalent) inject it here; adapters whose CLI has no * such concept simply ignore the field. Empty / undefined → CLI default. */ model?: string; + /** Optional per-turn reasoning effort (codex `model_reasoning_effort`). + * Only codex/codex-app adapters honor it; others ignore. */ + reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh'; /** When true, do not add adapter-default flags that bypass CLI approvals or disable sandboxing. */ disableCliBypass?: boolean; /** Optional session-scoped skill plugin/root prepared by botmux. */ diff --git a/src/codex-app-runner.ts b/src/codex-app-runner.ts index c80db0b24..80e7e2162 100644 --- a/src/codex-app-runner.ts +++ b/src/codex-app-runner.ts @@ -31,6 +31,8 @@ interface Args { botName?: string; botOpenId?: string; locale?: string; + model?: string; + reasoningEffort?: string; } interface PendingRequest { @@ -57,6 +59,8 @@ function parseArgs(argv: string[]): Args { else if (key === '--bot-name' && val !== undefined) { out.botName = val; i++; } else if (key === '--bot-open-id' && val !== undefined) { out.botOpenId = val; i++; } else if (key === '--locale' && val !== undefined) { out.locale = val; i++; } + else if (key === '--model' && val !== undefined) { out.model = val; i++; } + else if (key === '--reasoning-effort' && val !== undefined) { out.reasoningEffort = val; i++; } } if (!out.sessionId) throw new Error('--session-id is required'); return out; @@ -352,7 +356,16 @@ async function ensureThread(): Promise { cwd: args.cwd, approvalPolicy: 'never', sandbox: 'danger-full-access', - config: { shell_environment_policy: { inherit: 'all' } }, + config: { + shell_environment_policy: { inherit: 'all' }, + // Per-turn reasoning effort → codex config key (ThreadStartParams accepts an + // arbitrary config map). 'xhigh' collapses to codex's max 'high'. + ...(args.reasoningEffort ? { model_reasoning_effort: args.reasoningEffort === 'xhigh' ? 'high' : args.reasoningEffort } : {}), + }, + // Per-turn model override → ThreadStartParams top-level model. Only set on a + // fresh thread/start, so a fold-in (existing thread) keeps its frozen model — + // matching the API's fresh-spawn-only override semantics. + ...(args.model && args.model.trim() ? { model: args.model.trim() } : {}), serviceName: 'botmux', developerInstructions: appDeveloperInstructions(args), ephemeral: false, diff --git a/src/core/trigger-session.ts b/src/core/trigger-session.ts index b306d36c6..07b695330 100644 --- a/src/core/trigger-session.ts +++ b/src/core/trigger-session.ts @@ -574,6 +574,16 @@ export async function triggerSessionTurn( session.lastMessageAt = new Date(now).toISOString(); session.workingDir = wd.workingDir; session.cliId = bot.config.cliId; + // Per-turn model / reasoning-effort override (fresh spawn only). Stamped + // before the first fork so sessionAgentConfig freezes the chosen model and the + // init message carries the effort. A fold-in to an existing worker never + // reaches here, so overrides only apply to a newly-created session. + if (typeof req.options?.model === 'string' && req.options.model.trim()) { + session.model = req.options.model.trim(); + } + if (req.options?.reasoningEffort) { + session.reasoningEffort = req.options.reasoningEffort; + } sessionStore.updateSession(session); messageQueue.ensureQueue(anchor); diff --git a/src/core/worker-pool.ts b/src/core/worker-pool.ts index 6d5e68f3f..c945afc20 100644 --- a/src/core/worker-pool.ts +++ b/src/core/worker-pool.ts @@ -460,7 +460,7 @@ function sessionCliId(ds: DaemonSession, botCfg: { cliId: CliId }): CliId { function sessionAgentConfig( ds: DaemonSession, botCfg: { cliId: CliId; cliPathOverride?: string; wrapperCli?: string; model?: string }, -): { cliId: CliId; cliPathOverride?: string; wrapperCli?: string; model?: string } { +): { cliId: CliId; cliPathOverride?: string; wrapperCli?: string; model?: string; reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh' } { // Freeze the agent launch config (cli / cliPath / wrapper / model) onto the // session the first time a worker forks, so later bot-level edits never // retroactively change a live session — same discipline as `sandbox`. @@ -487,6 +487,7 @@ function sessionAgentConfig( cliPathOverride: ds.session.cliPathOverride, wrapperCli: ds.session.wrapperCli, model: ds.session.model, + reasoningEffort: ds.session.reasoningEffort, }; } @@ -2381,6 +2382,7 @@ export function forkWorker( wrapperCli: agentCfg.wrapperCli, launchShell: botCfg.launchShell, model: agentCfg.model, + reasoningEffort: agentCfg.reasoningEffort, disableCliBypass: botCfg.disableCliBypass === true, codexRpcInput: botCfg.codexRpcInput === true || config.codexRpcInputDefault, // Startup commands run on every fresh spawn (incl. resume) so session-only diff --git a/src/services/trigger-types.ts b/src/services/trigger-types.ts index 760f7211d..8f3d4b38f 100644 --- a/src/services/trigger-types.ts +++ b/src/services/trigger-types.ts @@ -51,6 +51,13 @@ export interface TriggerRequest { * this loud trigger's turn. The streaming card / start notice still show; * only the trailing transcript-driven summary is suppressed. */ suppressFinalOutput?: boolean; + /** Per-turn CLI model override (e.g. a codex model id). Applies only to a + * freshly-spawned session; ignored when folding into an existing worker. + * Empty/omitted → the bot's configured default. */ + model?: string; + /** Per-turn reasoning effort (codex `model_reasoning_effort`). Same + * fresh-spawn-only semantics as `model`. */ + reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh'; }; } @@ -181,5 +188,11 @@ export function validateTriggerRequest(raw: unknown): { ok: true; request: Trigg if (options.suppressFinalOutput !== undefined && typeof options.suppressFinalOutput !== 'boolean') { return { ok: false, status: 400, body: { ok: false, errorCode: 'bad_request', error: 'options.suppressFinalOutput must be a boolean' } }; } + if (options.model !== undefined && (typeof options.model !== 'string' || options.model.length > 200)) { + return { ok: false, status: 400, body: { ok: false, errorCode: 'bad_request', error: 'options.model must be a string (<=200 chars)' } }; + } + if (options.reasoningEffort !== undefined && !['low', 'medium', 'high', 'xhigh'].includes(options.reasoningEffort as string)) { + return { ok: false, status: 400, body: { ok: false, errorCode: 'bad_request', error: 'options.reasoningEffort must be one of low|medium|high|xhigh' } }; + } return { ok: true, request: raw as unknown as TriggerRequest }; } diff --git a/src/types.ts b/src/types.ts index ea8f739f3..b7d8b9c00 100644 --- a/src/types.ts +++ b/src/types.ts @@ -356,6 +356,9 @@ export interface Session { wrapperCli?: string; /** Optional model frozen at creation so historical sessions resume with their original model. */ model?: string; + /** Optional codex reasoning effort frozen at creation (per-turn API override). + * Only meaningful for codex/codex-app; injected as model_reasoning_effort at spawn. */ + reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh'; /** * True once `cliId`/`cliPathOverride`/`wrapperCli`/`model` have been frozen for * this session (see `sessionAgentConfig`). Gates the one-time freeze so it runs @@ -603,7 +606,7 @@ export interface CliTurnPayload { /** Messages sent from Daemon to Worker */ export type DaemonToWorker = - | { type: 'init'; sessionId: string; chatId: string; chatType?: 'group' | 'p2p'; rootMessageId: string; workingDir: string; cliId: string; cliPathOverride?: string; wrapperCli?: string; launchShell?: string; model?: string; disableCliBypass?: boolean; codexRpcInput?: boolean; startupCommands?: string[]; env?: Record; sandbox?: boolean; sandboxPaths?: { readWrite?: string[]; readOnly?: string[]; deny?: string[] }; sandboxHidePaths?: string[]; sandboxReadonlyPaths?: string[]; sandboxNetwork?: boolean; readIsolation?: boolean; readDenyExtraPaths?: string[]; daemonBootId?: string; backendType: BackendType; persistentBackendTarget?: PersistentBackendTarget; backendConfig?: RiffBackendConfig; riffParentTaskId?: string; riffRepoDirs?: string[]; deferredScheduleRun?: Session['deferredScheduleRun']; nativeSessionTitle?: string; nativeSessionTitlePrompt?: string; prompt: string; promptCodexAppInput?: CodexAppTurnInput; resume?: boolean; cliSessionId?: string; originalSessionId?: string; ownerOpenId?: string; webPort?: number; larkAppId: string; larkAppSecret: string; brand?: 'feishu' | 'lark'; botName?: string; botOpenId?: string; locale?: 'zh' | 'en'; turnId?: string; dispatchAttempt?: number; vcMeetingImTurnOrigin?: VcMeetingImTurnOrigin; pluginBindings?: string[]; skillPolicy?: BotSkillPolicy; skillPluginDir?: string; skillReadonlyRoots?: string[]; adoptMode?: boolean; adoptSource?: 'tmux' | 'herdr' | 'zellij'; adoptTmuxTarget?: string; adoptZellijSession?: string; adoptZellijPaneId?: string; adoptHerdrSessionName?: string; adoptHerdrTarget?: string; adoptHerdrPaneId?: string; adoptPaneCols?: number; adoptPaneRows?: number; bridgeJsonlPath?: string; adoptCliPid?: number; adoptCwd?: string; adoptRestoredFromMetadata?: boolean; runnerBuildId?: string; persistedRunnerBuildId?: string; restartAttemptId?: string } + | { type: 'init'; sessionId: string; chatId: string; chatType?: 'group' | 'p2p'; rootMessageId: string; workingDir: string; cliId: string; cliPathOverride?: string; wrapperCli?: string; launchShell?: string; model?: string; reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh'; disableCliBypass?: boolean; codexRpcInput?: boolean; startupCommands?: string[]; env?: Record; sandbox?: boolean; sandboxPaths?: { readWrite?: string[]; readOnly?: string[]; deny?: string[] }; sandboxHidePaths?: string[]; sandboxReadonlyPaths?: string[]; sandboxNetwork?: boolean; readIsolation?: boolean; readDenyExtraPaths?: string[]; daemonBootId?: string; backendType: BackendType; persistentBackendTarget?: PersistentBackendTarget; backendConfig?: RiffBackendConfig; riffParentTaskId?: string; riffRepoDirs?: string[]; deferredScheduleRun?: Session['deferredScheduleRun']; nativeSessionTitle?: string; nativeSessionTitlePrompt?: string; prompt: string; promptCodexAppInput?: CodexAppTurnInput; resume?: boolean; cliSessionId?: string; originalSessionId?: string; ownerOpenId?: string; webPort?: number; larkAppId: string; larkAppSecret: string; brand?: 'feishu' | 'lark'; botName?: string; botOpenId?: string; locale?: 'zh' | 'en'; turnId?: string; dispatchAttempt?: number; vcMeetingImTurnOrigin?: VcMeetingImTurnOrigin; pluginBindings?: string[]; skillPolicy?: BotSkillPolicy; skillPluginDir?: string; skillReadonlyRoots?: string[]; adoptMode?: boolean; adoptSource?: 'tmux' | 'herdr' | 'zellij'; adoptTmuxTarget?: string; adoptZellijSession?: string; adoptZellijPaneId?: string; adoptHerdrSessionName?: string; adoptHerdrTarget?: string; adoptHerdrPaneId?: string; adoptPaneCols?: number; adoptPaneRows?: number; bridgeJsonlPath?: string; adoptCliPid?: number; adoptCwd?: string; adoptRestoredFromMetadata?: boolean; runnerBuildId?: string; persistedRunnerBuildId?: string; restartAttemptId?: string } | { type: 'message'; content: string; codexAppInput?: CodexAppTurnInput; nativeSessionTitle?: string; nativeSessionTitlePrompt?: string; turnId?: string; dispatchAttempt?: number; vcMeetingImTurnOrigin?: VcMeetingImTurnOrigin } /** Literal slash-command passthrough. `followUpContent` rides along so the * worker enqueues it strictly AFTER the slash command's Enter — two separate diff --git a/src/worker.ts b/src/worker.ts index 911909e8b..2b347b107 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -7171,6 +7171,7 @@ async function spawnCli( larkAppId: cfg.larkAppId, locale: cfg.locale, model: ttadkGateway ? undefined : cfg.model, + reasoningEffort: cfg.reasoningEffort, disableCliBypass: cfg.disableCliBypass === true, skillPluginDir: cfg.skillPluginDir, readIsolation: willRedirectCliData, diff --git a/test/trigger-api.test.ts b/test/trigger-api.test.ts index 9764a2020..a549fa990 100644 --- a/test/trigger-api.test.ts +++ b/test/trigger-api.test.ts @@ -104,6 +104,28 @@ describe('trigger request contract', () => { if (!v.ok) expect(v.body.errorCode).toBe('bad_request'); }); + it('accepts per-turn model + reasoningEffort overrides', () => { + const req = request(); + req.options = { model: 'gpt-5.6-terra', reasoningEffort: 'high' }; + expect(validateTriggerRequest(req).ok).toBe(true); + }); + + it('rejects an invalid reasoningEffort value', () => { + const req = request(); + (req.options as any) = { reasoningEffort: 'ultra' }; + const v = validateTriggerRequest(req); + expect(v.ok).toBe(false); + if (!v.ok) expect(v.body.errorCode).toBe('bad_request'); + }); + + it('rejects a non-string / over-long model', () => { + for (const model of [42, 'x'.repeat(201)]) { + const req = request(); + (req.options as any) = { model }; + expect(validateTriggerRequest(req).ok).toBe(false); + } + }); + it('builds a prompt that labels event data as untrusted', () => { const prompt = buildUntrustedEventPrompt(request(), 'trg_1'); expect(prompt).toContain('untrusted event data'); From c6117a8817b75e069bbacb06bf24692f981d7e56 Mon Sep 17 00:00:00 2001 From: deepcoldy Date: Tue, 28 Jul 2026 12:57:23 +0000 Subject: [PATCH 2/6] =?UTF-8?q?fix(trigger):=20=E4=BF=AE=20codex=20PR=20A?= =?UTF-8?q?=20#639=20=E4=B8=89=E5=A4=84=20blocking=EF=BC=88RPC=20effort=20?= =?UTF-8?q?=E6=BC=8F=E4=BC=A0=20/=20xhigh=20=E9=99=8D=E7=BA=A7=20/=20?= =?UTF-8?q?=E5=BD=B1=E5=93=8D=E9=9D=A2=20gate=EF=BC=89+=20=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex 用 Codex 0.145 实测复审出三处,全部修: 1. **CodexRpcEngine 漏传 reasoningEffort**:worker 构造 engine 时只传了 model, 没传 reasoningEffort → RPC 真执行端拿不到(engine 本身支持 opts.reasoningEffort → config.model_reasoning_effort,只是没喂)。补 reasoningEffort: cfg.reasoningEffort。 2. **xhigh 被静默降 high**:codex 0.145 thread/start 实测接受 xhigh 并原样回显 (codex 亲测),所以 codex.ts 的 -c 与 codex-app-runner 的 thread/start 都改成 原样透传,去掉 xhigh→high 降级(降级会静默改变用户请求档位)。 3. **影响面契约不实**:原来没 gate,同步/普通 webhook 也会 stamp,且 model 会被 Claude/Gemini/CoCo 等 adapter 消费。改为在 trigger-session stamp 处 gate 到 **codex 家族**(cliId codex/codex-app):非 codex 目标忽略 model+effort,绝不 静默改其模型。reasoningEffort 其它 adapter 本就不解构(安全),model 由此 gate 兜住。 补:docs-site 中英文《API 编程式触发任务》补 options.model/reasoningEffort 说明 (仅 codex 家族生效、仅新建会话、xhigh 不降级);trigger-session 补 gate 单测 (codex-app 会 stamp / claude 不 stamp)。trigger-session 31 + trigger-api 27 测绿; build 绿;docs-site build 绿。 Co-Authored-By: Claude --- docs-site/docs/en/api-task-trigger.md | 4 ++++ docs-site/docs/zh/api-task-trigger.md | 4 ++++ src/adapters/cli/codex.ts | 6 ++--- src/codex-app-runner.ts | 5 +++-- src/core/trigger-session.ts | 23 +++++++++++-------- src/worker.ts | 2 +- test/trigger-session-root-message.test.ts | 27 +++++++++++++++++++++++ 7 files changed, 56 insertions(+), 15 deletions(-) diff --git a/docs-site/docs/en/api-task-trigger.md b/docs-site/docs/en/api-task-trigger.md index bab3d5299..7cd8aa097 100644 --- a/docs-site/docs/en/api-task-trigger.md +++ b/docs-site/docs/en/api-task-trigger.md @@ -56,6 +56,10 @@ Hard constraints (all validated; violations return 400): - **`envelope.trusted` must be `false`**. This is an injection-defense design: `trusted:false` declares "the envelope content is untrusted external data", so the daemon wraps it as an untrusted event and does not execute instructions embedded inside it. Put what you actually want the bot to do in the top-level `instruction` (the trusted directive), not in the envelope. - **Omitting `chatId`** requires `options` to contain either `waitForFinalOutput` or `asyncReturnSessionId`, else `target_required`. - **`options.timeoutMs` range `[1000, 300000]`** (1s–5min); out of range returns 400. Defaults to 120000. +- **`options.model`** / **`options.reasoningEffort`** (optional, **codex / codex-app bots only**): override the model and reasoning level for this trigger. + - `model`: a codex model id (≤200 chars); `reasoningEffort`: `low` / `medium` / `high` / `xhigh` (passed to codex verbatim — no downgrade). + - **Fresh-session only**: frozen only when this trigger **creates a new session**; a fold-in to an existing worker does not rewrite it. + - **Scoped to the codex family**: ignored when the target bot is not codex/codex-app (never changes a Claude/Gemini/CoCo bot's model). ### Sync mode (waitForFinalOutput) diff --git a/docs-site/docs/zh/api-task-trigger.md b/docs-site/docs/zh/api-task-trigger.md index 4cd84559f..0fc4ceabf 100644 --- a/docs-site/docs/zh/api-task-trigger.md +++ b/docs-site/docs/zh/api-task-trigger.md @@ -56,6 +56,10 @@ - **`envelope.trusted` 必须是 `false`**。这是防注入设计:`trusted:false` 声明「以下 envelope 内容是不可信外部数据」,daemon 才会把它包成 untrusted event、不执行里面夹带的指令。你要机器人真正执行的东西放在顶层 `instruction`(可信指令),不要放进 envelope。 - **不传 `chatId`** 时,`options` 必须含 `waitForFinalOutput` 或 `asyncReturnSessionId` 之一,否则报 `target_required`。 - **`options.timeoutMs` 范围 `[1000, 300000]`**(1 秒 ~ 5 分钟),越界报 400。不传默认 120000。 +- **`options.model`** / **`options.reasoningEffort`**(可选,**仅对 codex / codex-app 机器人生效**):按本次触发覆盖模型与推理档位。 + - `model`:codex 模型 id(≤200 字符);`reasoningEffort`:`low` / `medium` / `high` / `xhigh`(原样透传给 codex,不做降级)。 + - **仅新建会话生效**:只在这次触发**创建新会话**时冻结;折叠进已有 worker 的续轮不改写。 + - **作用域收窄到 codex 家族**:目标机器人不是 codex/codex-app 时,这两个字段被忽略(不会改动 Claude/Gemini/CoCo 等的模型)。 ### 同步模式(waitForFinalOutput) diff --git a/src/adapters/cli/codex.ts b/src/adapters/cli/codex.ts index b425d8174..52c33cda6 100644 --- a/src/adapters/cli/codex.ts +++ b/src/adapters/cli/codex.ts @@ -204,9 +204,9 @@ export function createCodexAdapter(pathOverride?: string): CliAdapter { } if (reasoningEffort) { // Per-turn reasoning effort → codex model_reasoning_effort(进程级 -c 覆盖, - // 不动用户全局 config)。codex 只接受 low/medium/high,botmux 'xhigh' 收敛到 'high'。 - const codexEffort = reasoningEffort === 'xhigh' ? 'high' : reasoningEffort; - baseArgs.push('-c', `model_reasoning_effort=${JSON.stringify(codexEffort)}`); + // 不动用户全局 config)。Codex 0.145 实测接受 low/medium/high/xhigh(xhigh + // 原样回显),故原样透传,不做降级——收敛会静默改变用户请求的档位。 + baseArgs.push('-c', `model_reasoning_effort=${JSON.stringify(reasoningEffort)}`); } // Codex app-server can keep its own cwd at $HOME; -C pins fresh agent roots. // NOTE: canonicalization of workingDir for the file sandbox is done ONCE in diff --git a/src/codex-app-runner.ts b/src/codex-app-runner.ts index 80e7e2162..69612cc4c 100644 --- a/src/codex-app-runner.ts +++ b/src/codex-app-runner.ts @@ -359,8 +359,9 @@ async function ensureThread(): Promise { config: { shell_environment_policy: { inherit: 'all' }, // Per-turn reasoning effort → codex config key (ThreadStartParams accepts an - // arbitrary config map). 'xhigh' collapses to codex's max 'high'. - ...(args.reasoningEffort ? { model_reasoning_effort: args.reasoningEffort === 'xhigh' ? 'high' : args.reasoningEffort } : {}), + // arbitrary config map). Codex 0.145 accepts low/medium/high/xhigh and echoes + // xhigh back verbatim, so pass it through unchanged (no downgrade). + ...(args.reasoningEffort ? { model_reasoning_effort: args.reasoningEffort } : {}), }, // Per-turn model override → ThreadStartParams top-level model. Only set on a // fresh thread/start, so a fold-in (existing thread) keeps its frozen model — diff --git a/src/core/trigger-session.ts b/src/core/trigger-session.ts index 07b695330..c6f7ca9be 100644 --- a/src/core/trigger-session.ts +++ b/src/core/trigger-session.ts @@ -574,15 +574,20 @@ export async function triggerSessionTurn( session.lastMessageAt = new Date(now).toISOString(); session.workingDir = wd.workingDir; session.cliId = bot.config.cliId; - // Per-turn model / reasoning-effort override (fresh spawn only). Stamped - // before the first fork so sessionAgentConfig freezes the chosen model and the - // init message carries the effort. A fold-in to an existing worker never - // reaches here, so overrides only apply to a newly-created session. - if (typeof req.options?.model === 'string' && req.options.model.trim()) { - session.model = req.options.model.trim(); - } - if (req.options?.reasoningEffort) { - session.reasoningEffort = req.options.reasoningEffort; + // Per-turn model / reasoning-effort override — scoped to codex-family bots + // (the documented B-mode target) and to a freshly-created trigger session. + // Gating on cliId keeps the contract honest and bounded: it never silently + // changes the model of a Claude/Gemini/CoCo bot, and a fold-in to an existing + // worker never reaches here. reasoningEffort is codex-only regardless (other + // adapters ignore it); model is gated here so it can't leak to non-codex CLIs. + const isCodexFamily = bot.config.cliId === 'codex' || bot.config.cliId === 'codex-app'; + if (isCodexFamily) { + if (typeof req.options?.model === 'string' && req.options.model.trim()) { + session.model = req.options.model.trim(); + } + if (req.options?.reasoningEffort) { + session.reasoningEffort = req.options.reasoningEffort; + } } sessionStore.updateSession(session); diff --git a/src/worker.ts b/src/worker.ts index 2b347b107..9f3ea8196 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -706,7 +706,7 @@ async function engageCodexRpc(cfg: Extract): P Object.assign(engineEnv, sanitizePerBotEnv(cfg.env)); engine = new CodexRpcEngine({ cliBin, cwd: cfg.workingDir, env: engineEnv, sessionId: cfg.sessionId, - model: cfg.model, log: (m: string) => log(m), + model: cfg.model, reasoningEffort: cfg.reasoningEffort, log: (m: string) => log(m), appServerFeatures: cfg.cliId === 'traex' ? ['default_mode_request_user_input'] : undefined, onRequestUserInput: cfg.cliId === 'traex' ? (params: unknown) => bridgeTraexUserInput(cfg, params) diff --git a/test/trigger-session-root-message.test.ts b/test/trigger-session-root-message.test.ts index e60f003cc..36fdd95fd 100644 --- a/test/trigger-session-root-message.test.ts +++ b/test/trigger-session-root-message.test.ts @@ -166,6 +166,33 @@ describe('triggerSessionTurn rootMessageId target', () => { expect(buildExternalEventTopicMessage(request(), APP)).toBe('外部事件触发:alerts'); }); + it('stamps per-turn model + reasoningEffort onto a codex-family session', async () => { + mockGetBot.mockReturnValue({ + config: { larkAppId: APP, cliId: 'codex-app', workingDir: '/tmp' }, + botName: 'Bot', botOpenId: 'ou_bot', + }); + const req = request(); + (req.options as any) = { model: 'gpt-5.6-terra', reasoningEffort: 'xhigh' }; + const activeSessions = new Map(); + await triggerSessionTurn(req, { larkAppId: APP, activeSessions }); + const ds = activeSessions.get(sessionKey(ROOT, APP)); + // xhigh preserved verbatim (no downgrade — codex 0.145 accepts it) + expect(ds?.session.model).toBe('gpt-5.6-terra'); + expect(ds?.session.reasoningEffort).toBe('xhigh'); + }); + + it('does NOT stamp model/effort onto a non-codex (claude) session', async () => { + // Harness default bot is claude-code. The gate must keep the override from + // silently changing a non-codex bot's model. + const req = request(); + (req.options as any) = { model: 'claude-opus-4-8', reasoningEffort: 'high' }; + const activeSessions = new Map(); + await triggerSessionTurn(req, { larkAppId: APP, activeSessions }); + const ds = activeSessions.get(sessionKey(ROOT, APP)); + expect(ds?.session.model).toBeUndefined(); + expect(ds?.session.reasoningEffort).toBeUndefined(); + }); + it('uses a connector-owned custom topic seed when opening a new topic', async () => { const req = request({ rootMessageId: undefined }); req.presentation = { topicMessage: 'CI 构建失败,请检查发布流水线' }; From 6f307cf96317b92055334511429c374921ac24ad Mon Sep 17 00:00:00 2001 From: deepcoldy Date: Tue, 28 Jul 2026 13:14:23 +0000 Subject: [PATCH 3/6] =?UTF-8?q?test(trigger):=20=E8=A1=A5=20PR=20A=20?= =?UTF-8?q?=E6=B6=88=E8=B4=B9=E9=93=BE=E6=B5=8B=E8=AF=95=EF=BC=88codex=20?= =?UTF-8?q?=E7=BB=88=E5=AE=A1=E8=A6=81=E6=B1=82=EF=BC=89=E2=80=94=E2=80=94?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E7=9C=9F=E6=AD=A3=E6=BC=8F=E8=BF=87=E7=9A=84?= =?UTF-8?q?=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex 终审:代码 blocker 已清,仅剩测试没保护"真正漏过的消费链"。补齐四条(都用 现有 fixture,不需真进程): 1. fold-in 不覆盖既有 model/effort:折叠进 live 会话走 existing-worker 分支、在 stamp 之前返回;显式断言 session.model/effort 不被 per-turn override 改写。 2. codex / codex-app adapter 参数:codex buildArgs 注入 -c model_reasoning_effort (xhigh 原样、不降级、无 effort 时不注入、--model);codex-app buildArgs 发 --model/--reasoning-effort(xhigh 原样、无则不发)。新增 codex-effort-wiring.test。 3. fake app-server 观察 thread/start:codex-rpc-engine.test 加用例,用真 fake app-server 起 thread,断言 thread/start.config.model + model_reasoning_effort=xhigh (fixture 加 FAKE_THREAD_CONFIG_FILE 记录入参)。 4. RPC engine config + worker→engine effort wiring:同上用例经真 CodexRpcEngine 构造(含 reasoningEffort opt)验证 config 转发。 trigger-session 32 + trigger-api 27 + codex-effort-wiring 6 + codex-rpc-engine 全绿; build 绿。 Co-Authored-By: Claude --- test/codex-effort-wiring.test.ts | 67 +++++++++++++++++++++++ test/codex-rpc-engine.test.ts | 24 +++++++- test/fixtures/fake-codex-rpc-server.mjs | 10 +++- test/trigger-session-root-message.test.ts | 22 ++++++++ 4 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 test/codex-effort-wiring.test.ts diff --git a/test/codex-effort-wiring.test.ts b/test/codex-effort-wiring.test.ts new file mode 100644 index 000000000..a4db4305d --- /dev/null +++ b/test/codex-effort-wiring.test.ts @@ -0,0 +1,67 @@ +/** + * codex-effort-wiring.test.ts + * + * Guards the per-turn model/reasoningEffort CONSUMPTION chain — the wiring that + * the first PR-A pass shipped untested (adapter → args / thread config), which + * is where codex review caught real gaps (RPC effort never reached the engine; + * xhigh silently downgraded). These assert the args/params a real codex actually + * receives, using existing fixtures — no live process needed. + */ +import { describe, it, expect, vi } from 'vitest'; + +// codex-app buildArgs resolves the codex binary via resolveCommand; return the +// path as-is so we can assert the emitted flags without shelling out. +vi.mock('../src/adapters/cli/registry.js', async (orig) => { + const actual = await orig(); + return { ...actual, resolveCommand: (bin: string) => bin }; +}); + +import { createCodexAdapter } from '../src/adapters/cli/codex.js'; +import { createCodexAppAdapter } from '../src/adapters/cli/codex-app.js'; + +const BASE = { sessionId: 's1', resume: false, workingDir: '/tmp' } as const; + +describe('codex adapter buildArgs — reasoningEffort injection', () => { + it('injects -c model_reasoning_effort verbatim (xhigh NOT downgraded)', () => { + const args = createCodexAdapter('/usr/bin/codex').buildArgs({ ...BASE, reasoningEffort: 'xhigh' }); + const i = args.indexOf('model_reasoning_effort="xhigh"'); + expect(i).toBeGreaterThan(0); + expect(args[i - 1]).toBe('-c'); + // must not appear as high — that would be the removed downgrade + expect(args.join(' ')).not.toContain('model_reasoning_effort="high"'); + }); + + it('passes each effort level through unchanged', () => { + for (const e of ['low', 'medium', 'high', 'xhigh'] as const) { + const args = createCodexAdapter('/usr/bin/codex').buildArgs({ ...BASE, reasoningEffort: e }); + expect(args.join(' ')).toContain(`model_reasoning_effort="${e}"`); + } + }); + + it('omits the -c effort flag when no effort is given', () => { + const args = createCodexAdapter('/usr/bin/codex').buildArgs({ ...BASE }); + expect(args.join(' ')).not.toContain('model_reasoning_effort'); + }); + + it('injects --model when provided', () => { + const args = createCodexAdapter('/usr/bin/codex').buildArgs({ ...BASE, model: 'gpt-5.6-terra' }); + const i = args.indexOf('gpt-5.6-terra'); + expect(args[i - 1]).toBe('--model'); + }); +}); + +describe('codex-app adapter buildArgs — runner flags', () => { + it('emits --model and --reasoning-effort (xhigh verbatim) for the runner', () => { + const args = createCodexAppAdapter('/usr/bin/codex').buildArgs({ ...BASE, model: 'gpt-5.6-terra', reasoningEffort: 'xhigh' }); + const mi = args.indexOf('--model'); + expect(args[mi + 1]).toBe('gpt-5.6-terra'); + const ei = args.indexOf('--reasoning-effort'); + expect(args[ei + 1]).toBe('xhigh'); + }); + + it('omits both flags when neither is given', () => { + const args = createCodexAppAdapter('/usr/bin/codex').buildArgs({ ...BASE }); + expect(args).not.toContain('--model'); + expect(args).not.toContain('--reasoning-effort'); + }); +}); diff --git a/test/codex-rpc-engine.test.ts b/test/codex-rpc-engine.test.ts index ee02bd75a..321b2b641 100644 --- a/test/codex-rpc-engine.test.ts +++ b/test/codex-rpc-engine.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, beforeAll } from 'vitest'; -import { chmodSync, mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs'; +import { chmodSync, mkdirSync, writeFileSync, existsSync, rmSync, readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { spawn } from 'node:child_process'; -import { homedir } from 'node:os'; +import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; import { CodexRpcEngine } from '../src/codex-rpc-engine.js'; @@ -59,6 +59,26 @@ describe('CodexRpcEngine — happy-path lifecycle against a fake app-server', () engine.stop(); }, 20_000); + it('forwards model + reasoningEffort (xhigh verbatim) into thread/start config', async () => { + // Guards the PR-A consumption gap codex caught: the engine must actually put + // model + model_reasoning_effort on thread/start config, and xhigh must NOT + // be downgraded (codex 0.145 accepts it). + const cfgFile = join(tmpdir(), `fake-thread-cfg-${Math.round(performance.now())}.json`); + const engine = makeEngine({ + sessionId: 'effort-wiring', + model: 'gpt-5.6-terra', + reasoningEffort: 'xhigh', + env: { ...process.env, FAKE_THREAD_CONFIG_FILE: cfgFile }, + }); + await engine.start(); + await engine.startThread(); + engine.stop(); + const params = JSON.parse(readFileSync(cfgFile, 'utf8')); + rmSync(cfgFile, { force: true }); + expect(params.config?.model).toBe('gpt-5.6-terra'); + expect(params.config?.model_reasoning_effort).toBe('xhigh'); + }, 20_000); + it('waits for resumed-thread metadata to advance before restoring its title', async () => { const engine = makeEngine({ sessionId: 'resume-title', diff --git a/test/fixtures/fake-codex-rpc-server.mjs b/test/fixtures/fake-codex-rpc-server.mjs index 5a136f979..0860ffa21 100755 --- a/test/fixtures/fake-codex-rpc-server.mjs +++ b/test/fixtures/fake-codex-rpc-server.mjs @@ -5,8 +5,11 @@ // requests. Env knobs drive the failure-path tests: // FAKE_HANG_TURN=1 → never answer turn/start (wedged app-server) // FAKE_DIE_AFTER_MS=N → exit(1) after N ms (crash → engine onDead) +// FAKE_THREAD_CONFIG_FILE=path → write the received thread/start params to path +// (lets a test assert model/effort forwarding) import { createServer } from 'node:http'; import { WebSocketServer } from 'ws'; +import { writeFileSync } from 'node:fs'; const listenArg = process.argv[process.argv.indexOf('--listen') + 1] || ''; const m = listenArg.match(/ws:\/\/127\.0\.0\.1:(\d+)/); @@ -51,7 +54,12 @@ wss.on('connection', (ws) => { const reply = (result) => ws.send(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result })); switch (msg.method) { case 'initialize': return reply({ ok: true }); - case 'thread/start': return reply({ thread: { id: 'thread-fake-1' } }); + case 'thread/start': { + if (process.env.FAKE_THREAD_CONFIG_FILE) { + try { writeFileSync(process.env.FAKE_THREAD_CONFIG_FILE, JSON.stringify(msg.params ?? {})); } catch { /* test-only */ } + } + return reply({ thread: { id: 'thread-fake-1' } }); + } case 'thread/resume': return reply({ thread: { id: msg.params?.threadId ?? 'thread-fake-1' } }); case 'thread/read': threadReadAttempt += 1; diff --git a/test/trigger-session-root-message.test.ts b/test/trigger-session-root-message.test.ts index 36fdd95fd..a3199dccf 100644 --- a/test/trigger-session-root-message.test.ts +++ b/test/trigger-session-root-message.test.ts @@ -252,6 +252,28 @@ describe('triggerSessionTurn rootMessageId target', () => { expect(send).toHaveBeenCalledWith({ type: 'message', content: expect.stringContaining('follow:') }); }); + it('fold-in to a live session does NOT overwrite its frozen model/effort', async () => { + // A per-turn override only applies to a freshly-created session. Folding into + // an existing worker must never rewrite the session's frozen model/effort + // (the existing-worker branch returns before the stamp; this locks that in). + mockGetBot.mockReturnValue({ + config: { larkAppId: APP, cliId: 'codex-app', workingDir: '/tmp' }, + botName: 'Bot', botOpenId: 'ou_bot', + }); + const send = vi.fn(); + const ds = existingDs({ worker: { killed: false, send } as any }); + ds.session.model = 'frozen-model'; + ds.session.reasoningEffort = 'low'; + const activeSessions = new Map([[sessionKey(ROOT, APP), ds]]); + const req = request(); + (req.options as any) = { model: 'new-model', reasoningEffort: 'xhigh' }; + await triggerSessionTurn(req, { larkAppId: APP, activeSessions }); + + expect(mockCreateSession).not.toHaveBeenCalled(); // folded in, not new + expect(ds.session.model).toBe('frozen-model'); + expect(ds.session.reasoningEffort).toBe('low'); + }); + it('uses an internal stable turn id without changing the public trigger schema', async () => { const send = vi.fn(); const ds = existingDs({ worker: { killed: false, send } as any, workerGeneration: 7 }); From c704985d02fd30689b7b43afc0c0e34e78c60a1f Mon Sep 17 00:00:00 2001 From: deepcoldy Date: Tue, 28 Jul 2026 13:24:40 +0000 Subject: [PATCH 4/6] =?UTF-8?q?test(trigger):=20=E8=A1=A5=20codex=20?= =?UTF-8?q?=E7=BB=88=E5=AE=A1=E4=B8=A4=E5=A4=84=E8=A6=86=E7=9B=96=E7=BC=BA?= =?UTF-8?q?=E5=8F=A3=E2=80=94=E2=80=94codex-app=20runner=20thread/start=20?= =?UTF-8?q?+=20worker=E2=86=92engine=20source=20lock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex 终审:代码无新问题,但第 3/4 条实际测成了同一条 pure RPC engine 路径,漏两跳: 1. codex-app 只测到 adapter 发 flags,没经 codex-app-runner 验最终 thread/start。 → 扩 codex-app-runner.integration harness(startRunner 加 extraArgs),新增用例: 用真 runner + fake app-server 断言 thread/start.params.model(顶层)+ config.model_reasoning_effort=xhigh(原样不降级)。 2. RPC 用例直接 makeEngine({reasoningEffort}),绕过 worker——删掉 worker.ts 的 `reasoningEffort: cfg.reasoningEffort` 仍绿,防不住上轮真实漏传。 → 按仓库现有 source-wiring 风格(hermes-worker-bridge-wiring 同款)加断言,读 worker.ts 锁 new CodexRpcEngine 构造含 model+reasoningEffort。负向验证:删该行 测试失败(已实测)。 codex-effort-wiring 8 + codex-app-runner.integration 8 + codex-rpc-engine + trigger 共 92 测绿;build 绿。 Co-Authored-By: Claude --- test/codex-app-runner.integration.test.ts | 27 +++++++++++++++++++++++ test/codex-effort-wiring.test.ts | 18 +++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/test/codex-app-runner.integration.test.ts b/test/codex-app-runner.integration.test.ts index 67db42884..b1ec92cb3 100644 --- a/test/codex-app-runner.integration.test.ts +++ b/test/codex-app-runner.integration.test.ts @@ -42,6 +42,7 @@ function startRunner( logPath: string, version: string, behavior: string, + extraArgs: string[] = [], ): Harness { let stdout = ''; let stderr = ''; @@ -55,6 +56,7 @@ function startRunner( fakeCodex, '--cwd', cwd, + ...extraArgs, ], { cwd: resolve('.'), env: { @@ -375,4 +377,29 @@ describe('codex-app-runner app-server protocol integration', () => { rmSync(dir, { recursive: true, force: true }); } }); + + it('forwards --model + --reasoning-effort into thread/start (top-level model + config.model_reasoning_effort, xhigh verbatim)', async () => { + // Runs the REAL codex-app-runner against the fake app-server and asserts the + // actual thread/start params — the hop the adapter-flag test cannot cover. + const dir = mkdtempSync(join(tmpdir(), 'botmux-codex-effort-')); + const fakeCodex = join(dir, 'fake-codex'); + const logPath = join(dir, 'requests.jsonl'); + copyFileSync(FAKE_SERVER_FIXTURE, fakeCodex); + chmodSync(fakeCodex, 0o755); + const harness = startRunner(fakeCodex, dir, logPath, '0.144.6', 'success', [ + '--model', 'gpt-5.6-terra', '--reasoning-effort', 'xhigh', + ]); + try { + await waitForOutput(harness, output => output.includes('Codex App connected.')); + harness.child.stdin.write(`${CONTROL_PREFIX}${encodeRunnerInput('hi', { text: 'hi' })}\r`); + await waitForOutput(harness, output => FINAL_MARKER.test(output)); + const threadStart = readRequests(logPath).find(r => r.method === 'thread/start'); + expect(threadStart).toBeTruthy(); + expect(threadStart.params.model).toBe('gpt-5.6-terra'); // top-level model + expect(threadStart.params.config?.model_reasoning_effort).toBe('xhigh'); // NOT downgraded + } finally { + await stopChild(harness.child); + rmSync(dir, { recursive: true, force: true }); + } + }); }); diff --git a/test/codex-effort-wiring.test.ts b/test/codex-effort-wiring.test.ts index a4db4305d..f911fb589 100644 --- a/test/codex-effort-wiring.test.ts +++ b/test/codex-effort-wiring.test.ts @@ -8,6 +8,7 @@ * receives, using existing fixtures — no live process needed. */ import { describe, it, expect, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; // codex-app buildArgs resolves the codex binary via resolveCommand; return the // path as-is so we can assert the emitted flags without shelling out. @@ -65,3 +66,20 @@ describe('codex-app adapter buildArgs — runner flags', () => { expect(args).not.toContain('--reasoning-effort'); }); }); + +describe('worker → CodexRpcEngine effort wiring (source lock)', () => { + // Source-wiring guard: the RPC engine unit test constructs the engine directly, + // so it would still pass if worker.ts stopped forwarding reasoningEffort. This + // locks the actual construction site — deleting the line fails here, catching + // exactly the regression codex caught last round (effort never reaching the + // real execution engine). + it('worker constructs CodexRpcEngine with reasoningEffort: cfg.reasoningEffort', () => { + const source = readFileSync(new URL('../src/worker.ts', import.meta.url), 'utf8'); + const ctor = source.indexOf('new CodexRpcEngine({'); + expect(ctor).toBeGreaterThan(0); + const end = source.indexOf('});', ctor); + const body = source.slice(ctor, end); + expect(body).toContain('model: cfg.model'); + expect(body).toContain('reasoningEffort: cfg.reasoningEffort'); + }); +}); From 755a5b8673a1bb263ab65913a33a449b20e9b2ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Wed, 29 Jul 2026 01:31:49 -0700 Subject: [PATCH 5/6] =?UTF-8?q?fix(codex):=20resume=20=E4=B8=8D=E4=B8=8B?= =?UTF-8?q?=E5=8F=91=20model/effort=EF=BC=8C=E9=81=BF=E5=85=8D=20provider?= =?UTF-8?q?=20=E9=9D=99=E9=BB=98=E6=BC=82=E7=A7=BB=EF=BC=88PR=20#639=20P2?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 双审收敛的 P2 blocker:codex/TraeX 共享的 RPC engine 与 codex-app runner 在 cold resume 时会重发 model/effort,命中 app-server 的 model-resume-override 短路(任一 model-related override 都会让 merge_persisted_resume_metadata 整组 early-return),把持久化的 {model, provider, effort} 三元组回落到当前进程默认。 - effort-only resume 漂移是本 PR 引入(c6117a88 在 worker.ts:709 首次把 effort 喂进 RPC engine); - model-only resume→provider 漂移是共享 engine 既有 latent bug,本 PR 新增的公开 options.model 扩大了其入口。 修法(不按 cliId 分叉,codex+TraeX 一次修好): - codex-rpc-engine:threadParams(forResume) 收窄,resumeThread 传 true → resume 的 config 不含 model / model_reasoning_effort,交还 app-server 恢复完整三元组; fresh thread/start 仍照发两项。 - codex-app-runner:thread/resume 分支本就不带 model/effort,补注释固化契约防回归。 真机验证:codex-cli 0.145.0 + traecli 0.200.19,resume 不带 override → 完整恢复 model-a/provider_a;只带 effort/model → 漂移。 测试(正负 + mutation 均验证有牙): - fake-codex-rpc-server 加 FAKE_RESUME_CONFIG_FILE 记录 thread/resume 入参; - codex-rpc-engine.test:①full-override resume 两项均缺席 + start 两项保留(同一 lifecycle 锁 start-keeps/resume-drops)②model-only resume 亦缺席(既有面,engine 无 cliId 一次覆盖 codex+TraeX); - codex-app-runner.integration:--thread-id + --model/--reasoning-effort 走 resume, 断言 thread/resume 不含 model 与 config.model_reasoning_effort、且未 fresh-start。 - 负向 mutation:还原任一侧修复 → 对应 resume 测试失败。 pnpm build 绿;相关 5 文件 95 tests 全绿(原 92 +3 新)。 Co-Authored-By: Claude --- src/codex-app-runner.ts | 6 +++ src/codex-rpc-engine.ts | 24 ++++++++-- test/codex-app-runner.integration.test.ts | 32 ++++++++++++++ test/codex-rpc-engine.test.ts | 54 +++++++++++++++++++++++ test/fixtures/fake-codex-rpc-server.mjs | 10 ++++- 5 files changed, 121 insertions(+), 5 deletions(-) diff --git a/src/codex-app-runner.ts b/src/codex-app-runner.ts index 69612cc4c..f842e2d6c 100644 --- a/src/codex-app-runner.ts +++ b/src/codex-app-runner.ts @@ -333,6 +333,12 @@ async function ensureThread(): Promise { cwd: args.cwd, approvalPolicy: 'never', sandbox: 'danger-full-access', + // Intentionally NO model / model_reasoning_effort here: on resume the + // app-server restores the thread's persisted {model, provider, effort} + // triple, and sending any single override would short-circuit that + // restoration (drifting model/provider to the current default). Per-turn + // overrides are applied on the fresh thread/start below only. Mirrors the + // RPC engine's resume contract (see codex-rpc-engine.resumeThread). config: { shell_environment_policy: { inherit: 'all' } }, developerInstructions: appDeveloperInstructions(args), excludeTurns: true, diff --git a/src/codex-rpc-engine.ts b/src/codex-rpc-engine.ts index 3f8bec2e0..1661ed379 100644 --- a/src/codex-rpc-engine.ts +++ b/src/codex-rpc-engine.ts @@ -169,21 +169,37 @@ export class CodexRpcEngine { * so RPC mode stays engaged across daemon restarts instead of reverting to * the paste path. */ async resumeThread(threadId: string): Promise { - const params: Json = { ...this.threadParams(), threadId, excludeTurns: true }; + // forResume=true: a cold resume must NOT re-send ANY model-related override. + // The codex/TraeX app-server sees any single override (model OR + // model_reasoning_effort) as "caller is pinning config" and early-returns out + // of `merge_persisted_resume_metadata`, dropping the rest of the persisted + // {model, model_provider, reasoning_effort} triple back to the CURRENT + // process default. Re-sending only effort (per-turn override, new in PR #639) + // — or even the stable configured model (pre-existing on the shared engine) — + // therefore silently drifts model/provider whenever the app-server default + // changed between restarts. Verified on codex-cli 0.145.0 + traecli 0.200.19. + // The safe path is to send nothing model-related and let the app-server + // restore the full persisted triple. Fresh thread/start still stamps both. + const params: Json = { ...this.threadParams(true), threadId, excludeTurns: true }; delete params.serviceName; // resume keeps the original thread's identity const r = await this.request('thread/resume', params); this.threadId = String(r?.thread?.id ?? threadId); return this.threadId; } - private threadParams(): Json { + private threadParams(forResume = false): Json { const config: Json = { // Forward the full env (incl. BOTMUX_SESSION_ID / BOTMUX_LARK_APP_ID) to // shell subprocesses so `botmux send` from within codex finds its bot. shell_environment_policy: { inherit: 'all', ignore_default_excludes: true }, }; - if (this.opts.model) config.model = this.opts.model; - if (this.opts.reasoningEffort) config.model_reasoning_effort = this.opts.reasoningEffort; + // Only stamp model/effort on a FRESH thread/start. On resume the app-server + // owns restoration of the persisted triple (see resumeThread) — sending + // either here would trip the app-server's model-resume-override short-circuit. + if (!forResume) { + if (this.opts.model) config.model = this.opts.model; + if (this.opts.reasoningEffort) config.model_reasoning_effort = this.opts.reasoningEffort; + } return { cwd: this.opts.cwd, approvalPolicy: 'never', diff --git a/test/codex-app-runner.integration.test.ts b/test/codex-app-runner.integration.test.ts index b1ec92cb3..2dc2e064b 100644 --- a/test/codex-app-runner.integration.test.ts +++ b/test/codex-app-runner.integration.test.ts @@ -402,4 +402,36 @@ describe('codex-app-runner app-server protocol integration', () => { rmSync(dir, { recursive: true, force: true }); } }); + + it('SUPPRESSES model/effort on thread/resume even when --model/--reasoning-effort are passed (no resume drift)', async () => { + // PR #639 P2 regression lock, runner side: a resume (--thread-id present) + // routes to thread/resume, and even though the adapter still forwards + // --model/--reasoning-effort on argv, the resume request must carry NEITHER + // top-level model NOR config.model_reasoning_effort — else the app-server's + // model-resume-override short-circuit drops the persisted triple to the + // current default. Fresh thread/start (the test above) still stamps both. + const dir = mkdtempSync(join(tmpdir(), 'botmux-codex-resume-suppress-')); + const fakeCodex = join(dir, 'fake-codex'); + const logPath = join(dir, 'requests.jsonl'); + copyFileSync(FAKE_SERVER_FIXTURE, fakeCodex); + chmodSync(fakeCodex, 0o755); + const harness = startRunner(fakeCodex, dir, logPath, '0.144.6', 'success', [ + '--thread-id', 'thread-existing-1', '--model', 'gpt-5.6-terra', '--reasoning-effort', 'xhigh', + ]); + try { + await waitForOutput(harness, output => output.includes('Codex App connected.')); + harness.child.stdin.write(`${CONTROL_PREFIX}${encodeRunnerInput('hi', { text: 'hi' })}\r`); + await waitForOutput(harness, output => FINAL_MARKER.test(output)); + const requests = readRequests(logPath); + const resume = requests.find(r => r.method === 'thread/resume'); + const start = requests.find(r => r.method === 'thread/start'); + expect(resume).toBeTruthy(); // routed to resume, not start + expect(start).toBeFalsy(); // a warm resume must not fresh-start + expect(resume.params.model).toBeUndefined(); // no top-level model + expect(resume.params.config?.model_reasoning_effort).toBeUndefined(); // no effort + } finally { + await stopChild(harness.child); + rmSync(dir, { recursive: true, force: true }); + } + }); }); diff --git a/test/codex-rpc-engine.test.ts b/test/codex-rpc-engine.test.ts index 321b2b641..543ecd64a 100644 --- a/test/codex-rpc-engine.test.ts +++ b/test/codex-rpc-engine.test.ts @@ -79,6 +79,60 @@ describe('CodexRpcEngine — happy-path lifecycle against a fake app-server', () expect(params.config?.model_reasoning_effort).toBe('xhigh'); }, 20_000); + it('SUPPRESSES model + reasoningEffort on thread/resume (start keeps both) — no resume drift', async () => { + // Regression lock for the PR #639 P2: a cold resume must send NEITHER + // config.model NOR config.model_reasoning_effort, or the app-server's + // model-resume-override short-circuit drops the persisted {model, provider, + // effort} triple to the current default. Fresh start still stamps both. + // Asserts start-keeps AND resume-drops in ONE engine lifecycle so the two + // paths can't silently converge. (a) effort-only face is covered by the + // model-less test below; this locks the full-override face. + const startFile = join(tmpdir(), `fake-start-cfg-${Math.round(performance.now())}.json`); + const resumeFile = join(tmpdir(), `fake-resume-cfg-${Math.round(performance.now())}.json`); + const engine = makeEngine({ + sessionId: 'resume-suppress', + model: 'gpt-5.6-terra', + reasoningEffort: 'xhigh', + env: { ...process.env, FAKE_THREAD_CONFIG_FILE: startFile, FAKE_RESUME_CONFIG_FILE: resumeFile }, + }); + await engine.start(); + await engine.startThread(); + await engine.resumeThread('thread-fake-1'); + engine.stop(); + const startParams = JSON.parse(readFileSync(startFile, 'utf8')); + const resumeParams = JSON.parse(readFileSync(resumeFile, 'utf8')); + rmSync(startFile, { force: true }); + rmSync(resumeFile, { force: true }); + // start (positive): both present, xhigh verbatim + expect(startParams.config?.model).toBe('gpt-5.6-terra'); + expect(startParams.config?.model_reasoning_effort).toBe('xhigh'); + // resume (negative): NEITHER present — the whole point of the fix + expect(resumeParams.config?.model).toBeUndefined(); + expect(resumeParams.config?.model_reasoning_effort).toBeUndefined(); + }, 20_000); + + it('SUPPRESSES a stable configured model on thread/resume (pre-existing shared-engine face)', async () => { + // Covers the pre-existing model-only drift the same fix closes: even a model + // that never changes (a TraeX/codex bot's configured model, no per-turn + // effort) must NOT be re-sent on resume, else provider drifts. engine has no + // cliId — this one assertion covers both codex and TraeX. + const resumeFile = join(tmpdir(), `fake-resume-model-only-${Math.round(performance.now())}.json`); + const engine = makeEngine({ + sessionId: 'resume-model-only', + model: 'gpt-5.6-terra', // configured model, no reasoningEffort + env: { ...process.env, FAKE_RESUME_CONFIG_FILE: resumeFile }, + }); + await engine.start(); + await engine.resumeThread('thread-fake-1'); + engine.stop(); + const resumeParams = JSON.parse(readFileSync(resumeFile, 'utf8')); + rmSync(resumeFile, { force: true }); + expect(resumeParams.config?.model).toBeUndefined(); + expect(resumeParams.config?.model_reasoning_effort).toBeUndefined(); + // sanity: resume still carries the non-model params it must (env policy) + expect(resumeParams.config?.shell_environment_policy).toBeTruthy(); + }, 20_000); + it('waits for resumed-thread metadata to advance before restoring its title', async () => { const engine = makeEngine({ sessionId: 'resume-title', diff --git a/test/fixtures/fake-codex-rpc-server.mjs b/test/fixtures/fake-codex-rpc-server.mjs index 0860ffa21..0621aad7c 100755 --- a/test/fixtures/fake-codex-rpc-server.mjs +++ b/test/fixtures/fake-codex-rpc-server.mjs @@ -7,6 +7,9 @@ // FAKE_DIE_AFTER_MS=N → exit(1) after N ms (crash → engine onDead) // FAKE_THREAD_CONFIG_FILE=path → write the received thread/start params to path // (lets a test assert model/effort forwarding) +// FAKE_RESUME_CONFIG_FILE=path → write the received thread/resume params to path +// (lets a test assert model/effort are SUPPRESSED +// on resume) import { createServer } from 'node:http'; import { WebSocketServer } from 'ws'; import { writeFileSync } from 'node:fs'; @@ -60,7 +63,12 @@ wss.on('connection', (ws) => { } return reply({ thread: { id: 'thread-fake-1' } }); } - case 'thread/resume': return reply({ thread: { id: msg.params?.threadId ?? 'thread-fake-1' } }); + case 'thread/resume': { + if (process.env.FAKE_RESUME_CONFIG_FILE) { + try { writeFileSync(process.env.FAKE_RESUME_CONFIG_FILE, JSON.stringify(msg.params ?? {})); } catch { /* test-only */ } + } + return reply({ thread: { id: msg.params?.threadId ?? 'thread-fake-1' } }); + } case 'thread/read': threadReadAttempt += 1; return reply({ thread: { From 6e79f373ffc7399a5040928e3b01a3aecf1cfae0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Wed, 29 Jul 2026 01:52:39 -0700 Subject: [PATCH 6/6] =?UTF-8?q?test(codex):=20=E8=A1=A5=20effort-only=20re?= =?UTF-8?q?sume=20=E7=8B=AC=E7=AB=8B=E7=94=A8=E4=BE=8B=EF=BC=88codex=20?= =?UTF-8?q?=E5=A4=8D=E5=AE=A1=E8=A6=81=E6=B1=82=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex 复审 755a5b86 指出回归矩阵缺一条精确用例:原 codex-rpc-engine 新增 resume 测试只有「model+effort 同时」与「model-only」,注释却称 effort-only 由 model-less test 覆盖——但那条实际设了 model。effort-only(model 缺席、 只设 reasoningEffort)是本 PR 新激活的入口(worker.ts:709 首次喂 effort), 且是与 model-only 不同的短路触发点(app-server 对任一 model-related key 都 early-return),两条既有用例都设了 model、无法 subsume。 - 补 effort-only resume 用例:只设 reasoningEffort、model 留空,断言 cold resume 的 config.model 与 config.model_reasoning_effort 均缺席; - 修正 full-override 用例里那句不准确的注释(改为「三面各自独立锁,无单条 subsume 其它」)。 - 负向 mutation 实测:还原 threadParams 的 forResume 收窄 → 本用例如期红。 pnpm build 绿;相关 5 文件 96 tests 全绿(+1)。 Co-Authored-By: Claude --- test/codex-rpc-engine.test.ts | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/test/codex-rpc-engine.test.ts b/test/codex-rpc-engine.test.ts index 543ecd64a..96556c9d4 100644 --- a/test/codex-rpc-engine.test.ts +++ b/test/codex-rpc-engine.test.ts @@ -85,8 +85,9 @@ describe('CodexRpcEngine — happy-path lifecycle against a fake app-server', () // model-resume-override short-circuit drops the persisted {model, provider, // effort} triple to the current default. Fresh start still stamps both. // Asserts start-keeps AND resume-drops in ONE engine lifecycle so the two - // paths can't silently converge. (a) effort-only face is covered by the - // model-less test below; this locks the full-override face. + // paths can't silently converge. This locks the full-override face; the + // model-only and effort-only faces are locked independently below (each is a + // distinct short-circuit trigger, so no single test subsumes the others). const startFile = join(tmpdir(), `fake-start-cfg-${Math.round(performance.now())}.json`); const resumeFile = join(tmpdir(), `fake-resume-cfg-${Math.round(performance.now())}.json`); const engine = makeEngine({ @@ -133,6 +134,30 @@ describe('CodexRpcEngine — happy-path lifecycle against a fake app-server', () expect(resumeParams.config?.shell_environment_policy).toBeTruthy(); }, 20_000); + it('SUPPRESSES an effort-only override on thread/resume (the exact entry PR #639 newly activated)', async () => { + // The combination-sensitive face codex asked to lock independently: model + // ABSENT, reasoningEffort SET. This is the path PR #639 opened (worker.ts:709 + // first fed effort into the engine), and it is a DISTINCT short-circuit + // trigger from model-only — the app-server early-returns on ANY single + // model-related key, so sending only model_reasoning_effort still drifts. The + // full-override and model-only tests above do NOT subsume it (both set model). + const resumeFile = join(tmpdir(), `fake-resume-effort-only-${Math.round(performance.now())}.json`); + const engine = makeEngine({ + sessionId: 'resume-effort-only', + reasoningEffort: 'xhigh', // effort set, model deliberately left unset + env: { ...process.env, FAKE_RESUME_CONFIG_FILE: resumeFile }, + }); + await engine.start(); + await engine.resumeThread('thread-fake-1'); + engine.stop(); + const resumeParams = JSON.parse(readFileSync(resumeFile, 'utf8')); + rmSync(resumeFile, { force: true }); + expect(resumeParams.config?.model).toBeUndefined(); + expect(resumeParams.config?.model_reasoning_effort).toBeUndefined(); + // sanity: resume still carries the non-model params it must (env policy) + expect(resumeParams.config?.shell_environment_policy).toBeTruthy(); + }, 20_000); + it('waits for resumed-thread metadata to advance before restoring its title', async () => { const engine = makeEngine({ sessionId: 'resume-title',