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-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..52c33cda6 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 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 // 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..f842e2d6c 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; @@ -329,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, @@ -352,7 +362,17 @@ 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). 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 — + // 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/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/src/core/trigger-session.ts b/src/core/trigger-session.ts index b306d36c6..c6f7ca9be 100644 --- a/src/core/trigger-session.ts +++ b/src/core/trigger-session.ts @@ -574,6 +574,21 @@ 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 — 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); 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..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) @@ -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/codex-app-runner.integration.test.ts b/test/codex-app-runner.integration.test.ts index 67db42884..2dc2e064b 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,61 @@ 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 }); + } + }); + + 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-effort-wiring.test.ts b/test/codex-effort-wiring.test.ts new file mode 100644 index 000000000..f911fb589 --- /dev/null +++ b/test/codex-effort-wiring.test.ts @@ -0,0 +1,85 @@ +/** + * 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'; +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. +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'); + }); +}); + +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'); + }); +}); diff --git a/test/codex-rpc-engine.test.ts b/test/codex-rpc-engine.test.ts index ee02bd75a..96556c9d4 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,105 @@ 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('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. 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({ + 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('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', diff --git a/test/fixtures/fake-codex-rpc-server.mjs b/test/fixtures/fake-codex-rpc-server.mjs index 5a136f979..0621aad7c 100755 --- a/test/fixtures/fake-codex-rpc-server.mjs +++ b/test/fixtures/fake-codex-rpc-server.mjs @@ -5,8 +5,14 @@ // 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) +// 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'; const listenArg = process.argv[process.argv.indexOf('--listen') + 1] || ''; const m = listenArg.match(/ws:\/\/127\.0\.0\.1:(\d+)/); @@ -51,8 +57,18 @@ 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/resume': return reply({ thread: { id: msg.params?.threadId ?? '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': { + 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: { 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'); diff --git a/test/trigger-session-root-message.test.ts b/test/trigger-session-root-message.test.ts index e60f003cc..a3199dccf 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 构建失败,请检查发布流水线' }; @@ -225,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 });