diff --git a/src/adapters/cli/codex-app.ts b/src/adapters/cli/codex-app.ts index 4614042ba..69b88e722 100644 --- a/src/adapters/cli/codex-app.ts +++ b/src/adapters/cli/codex-app.ts @@ -3,7 +3,7 @@ import { existsSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { resolveCommand } from './registry.js'; import type { CliAdapter, PtyHandle } from './types.js'; -import { writeRunnerInput } from './runner-input.js'; +import { writeRunnerInput, writeRunnerAnswer } from './runner-input.js'; function runnerPath(): string { const here = dirname(fileURLToPath(import.meta.url)); @@ -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 config (model + model_reasoning_effort). + pushOpt(args, '--model', model && model.trim() ? model.trim() : undefined); + pushOpt(args, '--reasoning-effort', reasoningEffort); return args; }, @@ -78,6 +82,12 @@ export function createCodexAppAdapter(pathOverride?: string): CliAdapter { return writeRunnerInput(pty, '::botmux-codex-app:', content, codexAppInput); }, + async writeInteractionAnswer(pty, interactionId, text) { + // Resolve a held awaiting_input interaction (structured clarification / + // elicitation) — same control channel as input, distinct payload type. + return writeRunnerAnswer(pty, '::botmux-codex-app:', interactionId, text); + }, + completionPattern: undefined, readyPattern: /›/, systemHints: [], diff --git a/src/adapters/cli/codex.ts b/src/adapters/cli/codex.ts index 2d815726a..0669677ea 100644 --- a/src/adapters/cli/codex.ts +++ b/src/adapters/cli/codex.ts @@ -145,7 +145,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 @@ -193,6 +193,13 @@ 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'(codex 无更高档)。 + 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/runner-input.ts b/src/adapters/cli/runner-input.ts index 37b03520e..80287618f 100644 --- a/src/adapters/cli/runner-input.ts +++ b/src/adapters/cli/runner-input.ts @@ -48,6 +48,13 @@ export function encodeRunnerInput(content: string, codexAppInput?: CodexAppTurnI return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64'); } +/** Encode a structured-interaction answer (resolves a held awaiting_input in the + * codex-app runner) rather than a new turn message. */ +export function encodeRunnerAnswer(interactionId: string, text: string): string { + const payload = { type: 'answer', interactionId, text }; + return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64'); +} + /** Split an ASCII string into <=maxBytes pieces. Safe because the caller only * ever passes `marker + base64`, which is single-byte throughout. */ export function chunkAscii(line: string, maxBytes: number): string[] { @@ -85,7 +92,24 @@ export async function writeRunnerInput( content: string, codexAppInput?: CodexAppTurnInput, ): Promise<{ submitted: boolean }> { - const line = `${markerPrefix}${encodeRunnerInput(content, codexAppInput)}`; + return writeRunnerEncodedLine(pty, `${markerPrefix}${encodeRunnerInput(content, codexAppInput)}`); +} + +/** Send a pre-encoded runner control line (e.g. an interaction answer). Shares + * the same chunked/throttled + pre-flush submit path as writeRunnerInput. */ +export async function writeRunnerAnswer( + pty: PtyHandle, + markerPrefix: string, + interactionId: string, + text: string, +): Promise<{ submitted: boolean }> { + return writeRunnerEncodedLine(pty, `${markerPrefix}${encodeRunnerAnswer(interactionId, text)}`); +} + +async function writeRunnerEncodedLine( + pty: PtyHandle, + line: string, +): Promise<{ submitted: boolean }> { // Non-tmux fallback (raw PTY): a single write is fine — there's no send-keys // process to time out, and the PTY write isn't bounded the same way. diff --git a/src/adapters/cli/types.ts b/src/adapters/cli/types.ts index dd0a4e399..195fc33ff 100644 --- a/src/adapters/cli/types.ts +++ b/src/adapters/cli/types.ts @@ -94,6 +94,10 @@ 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. Empty/undefined → + * CLI/config default. */ + 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. */ @@ -215,6 +219,15 @@ export interface CliAdapter { recheck?: () => SubmitRecheckResult | Promise; }>; + /** Optional: resolve a held awaiting_input interaction (structured + * clarification / elicitation) with answer text. Only adapters running a + * structured runner that can hold interactions open (codex-app) implement it. */ + writeInteractionAnswer?( + pty: PtyHandle, + interactionId: string, + text: string, + ): Promise<{ submitted: boolean }>; + /** Optional: absolute path (with ~ expansion handled by caller) to the CLI's * skill directory. When set, `ensureSkills` will write/refresh skill files * into `{skillsDir}//SKILL.md`. Undefined = this CLI does not diff --git a/src/codex-app-runner.ts b/src/codex-app-runner.ts index b801c87e8..dbc88993d 100644 --- a/src/codex-app-runner.ts +++ b/src/codex-app-runner.ts @@ -21,6 +21,8 @@ interface Args { botName?: string; botOpenId?: string; locale?: string; + model?: string; + reasoningEffort?: string; } interface PendingRequest { @@ -70,6 +72,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; @@ -267,6 +271,114 @@ let codexVersionChecked = false; let codexVersion: CodexVersion | undefined; let cleanVersionWarningShown = false; +/** Structured interaction requests (requestUserInput / elicitation) that are + * held open awaiting a human/programmatic answer instead of being auto-declined. + * Keyed by a botmux-minted interactionId. `finish` maps the answer text to the + * codex-app protocol-shaped respond payload and replies to the app-server. */ +interface PendingInteraction { + interactionId: string; + kind: 'clarification' | 'confirmation' | 'authentication'; + finish: (answerText: string) => void; +} +const pendingInteractions = new Map(); +let interactionSeq = 0; + +/** codex-app's requestUserInput answer schema (Codex 0.145 generated types) is + * `{ answers: { [questionId]: { answers: string[] } } }` — a per-question map, + * each holding a string array. v1 collapses the single free-text reply (per the + * botmux↔riff plain-text contract) into the FIRST question's answers array; a + * multi-question schema is logged so it surfaces rather than silently dropping. */ +function mapAnswerToRequestUserInput(params: any, answerText: string): { answers: Record } { + const fieldIds: string[] = Array.isArray(params?.questions) + ? params.questions.map((q: any) => q?.id).filter((x: any) => typeof x === 'string') + : []; + if (fieldIds.length > 1) { + writeLine(`[codex-app] requestUserInput has ${fieldIds.length} questions; single text answer maps to first (id=${fieldIds[0]})`); + } + const key = fieldIds[0] ?? 'answer'; + return { answers: { [key]: { answers: [answerText] } } }; +} + +/** Best-effort question text from a codex-app interaction request. The exact + * param shape varies by codex version; try the common carriers and fall back + * to the caller's default. */ +function extractInteractionQuestion(params: any): string | undefined { + if (!params || typeof params !== 'object') return undefined; + const candidates = [ + params.message, + params.prompt, + params.question, + Array.isArray(params.questions) ? params.questions[0]?.prompt ?? params.questions[0]?.question ?? params.questions[0]?.label : undefined, + ]; + for (const c of candidates) { + if (typeof c === 'string' && c.trim()) return c.trim(); + } + return undefined; +} + +function extractInteractionDetails(params: any): string | undefined { + if (!params || typeof params !== 'object') return undefined; + const d = params.details ?? params.description ?? params.context; + return typeof d === 'string' && d.trim() ? d.trim() : undefined; +} + +/** Map an elicitation carrying OAuth/login links into the riff authChallenge + * shape. Returns undefined when no link-bearing structure is present (→ the + * interaction is treated as a plain clarification). */ +function extractAuthChallenge(params: any): { links: { url: string; label?: string }[]; userCode?: string; instructions?: string; expiresAt?: string } | undefined { + if (!params || typeof params !== 'object') return undefined; + const rawLinks = params.links ?? params.authLinks ?? (params.url ? [{ url: params.url }] : undefined); + if (!Array.isArray(rawLinks)) return undefined; + const links = rawLinks + .map((l: any) => (typeof l === 'string' ? { url: l } : (l && typeof l.url === 'string' ? { url: l.url, label: typeof l.label === 'string' ? l.label : undefined } : undefined))) + .filter((l: any): l is { url: string; label?: string } => !!l); + if (!links.length) return undefined; + return { + links, + userCode: typeof params.userCode === 'string' ? params.userCode : undefined, + instructions: typeof params.instructions === 'string' ? params.instructions : undefined, + expiresAt: typeof params.expiresAt === 'string' ? params.expiresAt : undefined, + }; +} + +/** Register a held interaction and emit the awaiting_input marker to the worker + * (→ daemon → trigger-result). turnId is the runner's active botmux turn so the + * caller can correlate its answer. */ +function beginInteraction(input: { + kind: 'clarification' | 'confirmation' | 'authentication'; + question: string; + details?: string; + authChallenge?: { links: { url: string; label?: string }[]; userCode?: string; instructions?: string; expiresAt?: string }; + finish: (answerText: string) => void; +}): void { + const interactionId = `cai_${args.sessionId}_${++interactionSeq}`; + pendingInteractions.set(interactionId, { interactionId, kind: input.kind, finish: input.finish }); + emitMarker('awaiting_input', { + interactionId, + turnId: activeTurn?.nativeTurnId ?? undefined, + kind: input.kind, + question: input.question, + ...(input.details ? { details: input.details } : {}), + ...(input.authChallenge ? { authChallenge: input.authChallenge } : {}), + }); +} + +/** Resolve a held interaction with answer text (from the runner input channel). + * Unknown/stale ids are ignored (the turn may have moved on). */ +function answerInteraction(interactionId: string, text: string): void { + const pending = pendingInteractions.get(interactionId); + if (!pending) { + writeLine(`[codex-app] answer for unknown interaction ${interactionId} (ignored)`); + return; + } + pendingInteractions.delete(interactionId); + try { + pending.finish(text); + } catch (err: any) { + writeLine(`[codex-app] failed to deliver interaction answer: ${err?.message ?? err}`); + } +} + function detectedCodexVersion(): CodexVersion | undefined { if (codexVersionChecked) return codexVersion; codexVersionChecked = true; @@ -313,11 +425,35 @@ function handleServerRequest(msg: JsonObject): boolean { return true; } if (method === 'item/tool/requestUserInput') { - client.respond(msg.id, { answers: {} }); + // Structured clarification: hold the request open and surface it as an + // awaiting_input interaction. The answer text arrives later via the runner + // input channel and is mapped back to codex-app's keyed `answers` shape. + const params: any = msg.params ?? {}; + const question = extractInteractionQuestion(params) + ?? 'The agent needs additional input to continue.'; + const details = extractInteractionDetails(params); + beginInteraction({ + kind: 'clarification', + question, + details, + finish: (text) => client.respond(msg.id, mapAnswerToRequestUserInput(params, text)), + }); return true; } if (method === 'mcpServer/elicitation/request') { - client.respond(msg.id, { action: 'cancel', content: null, _meta: null }); + const params: any = msg.params ?? {}; + const question = extractInteractionQuestion(params) + ?? (typeof params.message === 'string' ? params.message : 'The agent is requesting input.'); + const authChallenge = extractAuthChallenge(params); + beginInteraction({ + kind: authChallenge ? 'authentication' : 'clarification', + question, + details: extractInteractionDetails(params), + authChallenge, + // v1: any answered text = accept + single-field content; cancel is only + // used on timeout/no-answer (handled daemon-side, not here). + finish: (text) => client.respond(msg.id, { action: 'accept', content: { answer: text }, _meta: null }), + }); return true; } if (method === 'item/tool/call') { @@ -420,7 +556,14 @@ 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' }, + // model_reasoning_effort is a codex config key (no top-level thread field). + // 'xhigh' collapses to codex's max 'high'. + ...(args.reasoningEffort ? { model_reasoning_effort: args.reasoningEffort === 'xhigh' ? 'high' : args.reasoningEffort } : {}), + }, + // Per-turn model override → thread-level model (app-server's documented spot). + ...(args.model && args.model.trim() ? { model: args.model.trim() } : {}), serviceName: 'botmux', developerInstructions: appDeveloperInstructions(args), ephemeral: false, @@ -549,6 +692,12 @@ function enqueueLine(line: string): void { const encoded = trimmed.slice('::botmux-codex-app:'.length); try { const decoded = JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')); + if (decoded?.type === 'answer' && typeof decoded.interactionId === 'string') { + // Programmatic/human answer to a held structured interaction — resolve + // the codex-app request rather than enqueue a new turn. + answerInteraction(decoded.interactionId, typeof decoded.text === 'string' ? decoded.text : ''); + return; + } if (decoded?.type === 'message' && typeof decoded.content === 'string') { const codexAppInput = isCodexAppTurnInput(decoded.codexAppInput) ? decoded.codexAppInput diff --git a/src/core/cost-calculator.ts b/src/core/cost-calculator.ts index 76058df3b..735170cac 100644 --- a/src/core/cost-calculator.ts +++ b/src/core/cost-calculator.ts @@ -162,6 +162,7 @@ function usageKindForCli(cliId: SessionTokenUsageQuery['cliId']): UsageKind { case 'relay': return 'claude'; case 'codex': + case 'codex-app': // TRAE rollouts are byte-identical to Codex (see traex-transcript.ts): // token_count events carry the cumulative totals, and the active model // rides on turn_context/session_meta payloads. The generic fold picked up diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index 2f22e5f4c..0bf0b9046 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -11,6 +11,8 @@ import { listenWithProbe } from '../utils/listen-with-probe.js'; import { dashboardSecretPath } from './dashboard-secret.js'; import * as sessionStore from '../services/session-store.js'; import * as asyncTriggerStore from '../services/async-trigger-store.js'; +import { getSessionTokenUsage } from './cost-calculator.js'; +import type { CliId } from '../adapters/cli/types.js'; import { resolveAsyncTriggerState, decideAsyncOwnership } from '../services/async-trigger-state.js'; import * as scheduleStore from '../services/schedule-store.js'; import * as groupsStore from '../services/groups-store.js'; @@ -857,6 +859,23 @@ function buildAsyncTriggerLookupResponse(sessionId: string, triggerId?: string): const memTriggerId = triggerId || ds?.latestAsyncTriggerId; const memResult = ds && memTriggerId ? ds.asyncTriggerResults?.get(memTriggerId) : undefined; + // Only pay the transcript read when a completed result exists (mem or durable) + // — that's the sole state that emits usage. Best-effort: a null lookup (CLI + // unsupported / transcript gone after restart) just omits the usage field. + const isCompleted = memResult?.status === 'completed' || persisted?.result.status === 'completed'; + let usage: { inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheCreateTokens: number } | undefined; + if (isCompleted && stored) { + try { + const u = getSessionTokenUsage({ + cliId: (stored.cliId ?? 'claude-code') as CliId, + sessionId: stored.sessionId, + cliSessionId: stored.cliSessionId, + cwd: stored.workingDir, + }); + if (u) usage = { inputTokens: u.inputTokens, outputTokens: u.outputTokens, cacheReadTokens: u.cacheReadTokens, cacheCreateTokens: u.cacheCreateTokens }; + } catch { /* best-effort: omit usage on any read failure */ } + } + return resolveAsyncTriggerState({ sessionId, liveActive: !!ds, @@ -867,6 +886,18 @@ function buildAsyncTriggerLookupResponse(sessionId: string, triggerId?: string): storedStatus: stored ? (stored.status === 'closed' ? 'closed' : 'open') : undefined, closedAt: stored?.closedAt, requestedTriggerId: triggerId, + usage, + // A held interaction only exists on the live in-memory session (not persisted). + awaiting: ds?.awaitingInteraction + ? { + interactionId: ds.awaitingInteraction.interactionId, + turnId: ds.awaitingInteraction.turnId, + kind: ds.awaitingInteraction.kind, + question: ds.awaitingInteraction.question, + details: ds.awaitingInteraction.details, + authChallenge: ds.awaitingInteraction.authChallenge, + } + : undefined, }); } @@ -1059,6 +1090,38 @@ ipcRoute('GET', '/api/sessions/:sessionId/trigger-result', (req, res, params) => jsonRes(res, result.ok ? 200 : 400, result); }); +// Answer a held structured interaction (codex-app awaiting_input). The caller +// (task runner) polls trigger-result, sees state 'awaiting_input' + interaction, +// and POSTs the user's answer text here. Body: { interactionId, answer:{text} } +// (turnId/kind may also be sent by the caller for its own bookkeeping — we only +// require interactionId + answer.text). Resolves by forwarding to the worker, +// which writes it into the codex-app runner (→ codex-app respond). owner-only via +// the dashboard proxy's write-cookie gate; the daemon IPC is loopback-trusted. +ipcRoute('POST', '/api/sessions/:sessionId/answer', async (req, res, params) => { + let body: { interactionId?: unknown; answer?: { text?: unknown } }; + try { body = await readJsonBody(req); } catch { return jsonRes(res, 400, { ok: false, error: 'bad_json' }); } + const interactionId = typeof body.interactionId === 'string' ? body.interactionId : ''; + const text = typeof body.answer?.text === 'string' ? body.answer.text : ''; + if (!interactionId) return jsonRes(res, 400, { ok: false, error: 'interactionId_required' }); + const ds = findActiveBySessionId(params.sessionId); + if (!ds) return jsonRes(res, 404, { ok: false, error: 'session_not_active' }); + if (!ds.awaitingInteraction || ds.awaitingInteraction.interactionId !== interactionId) { + // Stale/unknown interaction — the turn may have moved on. Report cleanly so + // the caller re-polls rather than retrying a dead id. + return jsonRes(res, 409, { ok: false, error: 'interaction_not_pending' }); + } + if (!ds.worker || ds.worker.killed) return jsonRes(res, 409, { ok: false, error: 'no_live_worker' }); + try { + ds.worker.send({ type: 'answer_interaction', interactionId, text } as DaemonToWorker); + } catch { + return jsonRes(res, 502, { ok: false, error: 'worker_send_failed' }); + } + // Optimistically clear the pending marker; final_output will also clear it, and + // a re-poll before the turn resumes just won't find it pending (409 above). + ds.awaitingInteraction = undefined; + jsonRes(res, 200, { ok: true, sessionId: params.sessionId, interactionId }); +}); + // 会话 insight:只读解析本会话的 transcript,产出动作 span / 失败聚合 / 规则建议 // (SafeInsightReport)。底层 services/insight 已做 fail-closed 脱敏投影——raw 命令 // 与输出永不进结构。detail=summary 只返聚合+建议(/insight 卡片、抽屉概览用); diff --git a/src/core/trigger-session.ts b/src/core/trigger-session.ts index b306d36c6..f2ad61b96 100644 --- a/src/core/trigger-session.ts +++ b/src/core/trigger-session.ts @@ -574,6 +574,17 @@ 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). Stamp onto + // the session BEFORE the first fork so sessionAgentConfig freezes the chosen + // model (not the bot default) and the init message carries the effort. An + // existing-worker fold-in never reaches here, so overrides only apply to a + // newly-created session — matching the documented semantics. + 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/types.ts b/src/core/types.ts index 32d9ca1ec..71ff4a634 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -240,6 +240,18 @@ export interface DaemonSession { vcMeetingImTurnOrigin?: VcMeetingImTurnOrigin; /** message_id of the TUI prompt interactive card (if active) */ tuiPromptCardId?: string; + /** Structured awaiting_input interaction currently held open by a codex-app + * runner (programmatic API path). Surfaced via trigger-result state + * `awaiting_input` and cleared when the turn completes or is answered. */ + awaitingInteraction?: { + interactionId: string; + turnId?: string; + kind: 'clarification' | 'confirmation' | 'authentication'; + question: string; + details?: string; + authChallenge?: { links: { url: string; label?: string }[]; userCode?: string; instructions?: string; expiresAt?: string }; + at: number; + }; /** turnId of the last stuck_warning posted — dedup so we don't spam the * thread with repeated warnings for the same unresolved turn. */ stuckWarningTurnId?: string; diff --git a/src/core/worker-pool.ts b/src/core/worker-pool.ts index b23f5b005..eecb309ae 100644 --- a/src/core/worker-pool.ts +++ b/src/core/worker-pool.ts @@ -451,7 +451,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`. @@ -478,6 +478,7 @@ function sessionAgentConfig( cliPathOverride: ds.session.cliPathOverride, wrapperCli: ds.session.wrapperCli, model: ds.session.model, + reasoningEffort: ds.session.reasoningEffort, }; } @@ -2250,6 +2251,7 @@ export function forkWorker( wrapperCli: agentCfg.wrapperCli, launchShell: botCfg.launchShell, model: agentCfg.model, + reasoningEffort: ds.session.reasoningEffort, disableCliBypass: botCfg.disableCliBypass === true, codexRpcInput: botCfg.codexRpcInput === true || config.codexRpcInputDefault, // Startup commands run on every fresh spawn (incl. resume) so session-only @@ -3185,6 +3187,23 @@ function setupWorkerHandlers( break; } + case 'awaiting_input': { + // codex-app is holding a structured clarification/elicitation open. + // Record it so trigger-result reports state 'awaiting_input' and + // getMeta shows the needs-you signal; cleared on final_output / answer. + ds.awaitingInteraction = { + interactionId: msg.interactionId, + turnId: msg.turnId, + kind: msg.kind, + question: msg.question, + ...(msg.details ? { details: msg.details } : {}), + ...(msg.authChallenge ? { authChallenge: msg.authChallenge } : {}), + at: Date.now(), + }; + logger.info(`[${t}] awaiting_input interaction ${msg.interactionId} (${msg.kind})`); + break; + } + case 'tui_prompt_resolved': { // TUI prompt is no longer showing — update card if it exists logger.info(`[${t}] TUI prompt resolved${msg.selectedText ? `: ${msg.selectedText}` : ''}`); @@ -3710,6 +3729,10 @@ function setupWorkerHandlers( } case 'final_output': { + // A completed turn clears any held awaiting_input interaction — the + // clarification either got answered (turn resumed → completed) or the + // turn ended; either way it's no longer pending. + ds.awaitingInteraction = undefined; // Adopt-bridge: worker harvested the assistant turn from Claude Code's // transcript JSONL and forwarded it to us. Dedup with a session-scoped // key so a re-drain can't re-send the same answer or cross-suppress diff --git a/src/dashboard.ts b/src/dashboard.ts index 28b849c3e..4c23f18bb 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -3696,6 +3696,25 @@ const server = createServer(async (req, res) => { return; } + // 回答一个 awaiting_input 结构化交互(codex-app)。调用方轮询 trigger-result + // 拿到 state:awaiting_input 后,把用户答复文本 POST 到这里。写操作,非 GET — + // decideDashboardAuth 已要求管理 cookie,未授权在前面就被 401。代理到 owner daemon。 + if (req.method === 'POST' && (m = url.pathname.match(/^\/api\/sessions\/([^/]+)\/answer$/))) { + const sid = decodeURIComponent(m[1]); + const owner = aggregator.ownerOf(sid); + if (!owner) return jsonRes(res, 404, { ok: false, error: 'unknown_session' }); + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(c as Buffer); + const upstream = await proxyToDaemon(owner, `/api/sessions/${sid}/answer`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: Buffer.concat(chunks).toString('utf8'), + }); + res.writeHead(upstream.status, { 'content-type': 'application/json' }); + res.end(await upstream.text()); + return; + } + // 会话 insight(只读 trace 分析:动作 span / 失败聚合 / 规则建议)。 // owner-only:不在公开读白名单 → decideDashboardAuth 已对只读访客 401, // 公开/联邦访客看不到 tab 也拿不到 span。代理到 owner daemon 的同名 IPC。 diff --git a/src/services/async-trigger-state.ts b/src/services/async-trigger-state.ts index 34e713c48..a142a2905 100644 --- a/src/services/async-trigger-state.ts +++ b/src/services/async-trigger-state.ts @@ -83,6 +83,24 @@ export interface AsyncStateInputs { closedAt?: string; /** triggerId from the request query, if the caller pinned one. */ requestedTriggerId?: string; + /** Optional token usage for the completed turn (computed by the caller from + * the CLI transcript). Emitted only on the `completed` state. */ + usage?: { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreateTokens: number; + }; + /** A held structured interaction (codex-app awaiting_input). When present on a + * live/open session it takes precedence over `running`. */ + awaiting?: { + interactionId: string; + turnId?: string; + kind: 'clarification' | 'confirmation' | 'authentication'; + question: string; + details?: string; + authChallenge?: { links: { url: string; label?: string }[]; userCode?: string; instructions?: string; expiresAt?: string }; + }; } export function resolveAsyncTriggerState(inp: AsyncStateInputs): TriggerResponse { @@ -127,6 +145,7 @@ export function resolveAsyncTriggerState(inp: AsyncStateInputs): TriggerResponse action: 'completed', target: { kind: 'turn', sessionId, chatId }, output: completed.content !== undefined ? { content: completed.content } : undefined, + ...(inp.usage ? { usage: inp.usage } : {}), finishedAt, async: { status: 'completed', sessionId, completedAt: finishedAt }, message: 'async trigger completed', @@ -151,6 +170,21 @@ export function resolveAsyncTriggerState(inp: AsyncStateInputs): TriggerResponse }; } + // Awaiting input: a live session holding a structured interaction open. Takes + // precedence over plain `running` so the caller can suspend and answer. Only + // meaningful while the session is live/open (a closed session was handled above). + if (inp.awaiting && (inp.liveActive || inp.storedStatus === 'open')) { + return { + ok: true, + state: 'awaiting_input', + triggerId: inp.persisted?.triggerId ?? inp.requestedTriggerId, + target: { kind: 'turn', sessionId, chatId }, + interaction: inp.awaiting, + async: { status: 'pending', sessionId }, + message: 'async trigger awaiting user input', + }; + } + // Running: a live session (worker in flight), a still-open session record, // or a durable pending result with NO session record on disk (restart edge: // the trigger was armed but its session file is momentarily unavailable — diff --git a/src/services/transcript-resolver.ts b/src/services/transcript-resolver.ts index a9de9426c..eff2d09a7 100644 --- a/src/services/transcript-resolver.ts +++ b/src/services/transcript-resolver.ts @@ -112,7 +112,10 @@ export function resolveSessionTranscriptPath(q: TranscriptPathQuery): ResolvedTr const path = q.cwd ? getClaudeSessionJsonlPath(sid, q.cwd, claudeForkDataDir(q.cliId)) : null; return path ? { path, kind: 'claude' } : null; } - case 'codex': { + case 'codex': + case 'codex-app': { + // codex-app drives the codex app-server, which persists the same rollout + // format under the same store as plain codex — so resolution is identical. const path = cachedTranscriptPathLookup(`codex:${q.sessionId}:${q.cliSessionId ?? ''}`, null, () => { const codexSid = q.cliSessionId || findCodexSessionIdByBotmuxSessionId(q.sessionId) || q.sessionId; return findCodexRolloutBySessionId(codexSid) ?? null; diff --git a/src/services/trigger-types.ts b/src/services/trigger-types.ts index 760f7211d..4e7c471fe 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'; }; } @@ -82,7 +89,7 @@ export type TriggerErrorCode = * - failed: session terminated without a captured output (soft terminal — * may be a genuine failure OR a caller-initiated close/cancel) * - not_found: no session record on disk (never existed / invalid id) */ -export type AsyncTriggerState = 'running' | 'completed' | 'failed' | 'not_found'; +export type AsyncTriggerState = 'running' | 'completed' | 'failed' | 'not_found' | 'awaiting_input'; export interface TriggerResponse { ok: boolean; @@ -110,6 +117,25 @@ export interface TriggerResponse { output?: { content: string; }; + /** Token usage for the completed turn's session (best-effort, parsed from the + * CLI transcript). Present on `state:'completed'` when the transcript is + * readable; omitted otherwise. Field names mirror the caller's TaskTokenUsage. */ + usage?: { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreateTokens: number; + }; + /** Present only on `state:'awaiting_input'` — the held structured interaction + * the caller must answer via POST /api/sessions/:id/answer. */ + interaction?: { + interactionId: string; + turnId?: string; + kind: 'clarification' | 'confirmation' | 'authentication'; + question: string; + details?: string; + authChallenge?: { links: { url: string; label?: string }[]; userCode?: string; instructions?: string; expiresAt?: string }; + }; async?: { status: TriggerAsyncStatus; sessionId?: string; @@ -181,5 +207,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 e5bb00f37..29bd4527b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -334,6 +334,10 @@ 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 sessions; 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 @@ -581,7 +585,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 } + | { 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 } | { 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 @@ -617,6 +621,7 @@ export type DaemonToWorker = // 白名单 TUI 命令注入(/slash 路由)。cwd 移动不走注入——角色切换用 // restart+updateWorkingDir 的 respawn,避免绕过 cd 路由的角色库硬校验。 | { type: 'inject_command'; command: string } + | { type: 'answer_interaction'; interactionId: string; text: string } | { type: 'tui_text_input'; keys: string[]; text: string } // CoCo AskUserQuestion 作答:daemon 在 ask 结算后下发,worker 等原生 picker 渲染后 // 用 navKeys 驱动它选择+导航。needsReviewSubmit=true(多题)时 navKeys 停在 Review @@ -663,6 +668,7 @@ export type WorkerToDaemon = | { type: 'bridge_source_session'; bridge: 'hermes'; sourceSessionId: string } | { type: 'tui_prompt'; description: string; options: Array<{ label?: string; text: string; selected: boolean; type?: string; keys?: string[] }>; multiSelect?: boolean; turnId?: string; dispatchAttempt?: number } | { type: 'tui_prompt_resolved'; selectedText?: string; turnId?: string; dispatchAttempt?: number } + | { type: 'awaiting_input'; interactionId: string; turnId?: string; kind: 'clarification' | 'confirmation' | 'authentication'; question: string; details?: string; authChallenge?: { links: { url: string; label?: string }[]; userCode?: string; instructions?: string; expiresAt?: string } } | { type: 'stuck_warning'; elapsedMs: number; snapshot: string; matchedPattern?: string; turnId?: string; dispatchAttempt?: number; cliLifetime?: number } | { type: 'stuck_warning_expired'; nonce: number; turnId?: string; dispatchAttempt?: number } | { type: 'tui_keys_delivered'; nonce: number; turnId?: string; dispatchAttempt?: number } diff --git a/src/worker.ts b/src/worker.ts index 4c7472627..e506f3c73 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -4832,6 +4832,22 @@ function handleCodexAppMarker(body: string): void { return; } + if (kind === 'awaiting_input' && typeof payload.interactionId === 'string' && typeof payload.question === 'string') { + // codex-app is holding a structured clarification/elicitation open. Surface + // it to the daemon so trigger-result/getMeta can report awaiting_input and + // the caller can answer via POST /api/sessions/:id/answer. + send({ + type: 'awaiting_input', + interactionId: payload.interactionId, + turnId: typeof payload.turnId === 'string' ? payload.turnId : currentBotmuxTurnId, + kind: payload.kind === 'confirmation' || payload.kind === 'authentication' ? payload.kind : 'clarification', + question: payload.question, + ...(typeof payload.details === 'string' ? { details: payload.details } : {}), + ...(payload.authChallenge && typeof payload.authChallenge === 'object' ? { authChallenge: payload.authChallenge } : {}), + } as WorkerToDaemon); + return; + } + if (kind === 'final' && typeof payload.content === 'string') { const startedAtMs = typeof payload.startedAtMs === 'number' ? payload.startedAtMs : undefined; const completedAtMs = typeof payload.completedAtMs === 'number' ? payload.completedAtMs : Date.now(); @@ -6947,6 +6963,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, @@ -10241,6 +10258,20 @@ process.on('message', async (raw: unknown) => { break; } + case 'answer_interaction': { + // Resolve a held codex-app awaiting_input interaction with answer text. + // Only the structured runner (codex-app) implements writeInteractionAnswer; + // other CLIs never emit awaiting_input so this never reaches them. + if (backend && cliAdapter?.writeInteractionAnswer) { + void cliAdapter.writeInteractionAnswer(backend as unknown as PtyHandle, msg.interactionId, msg.text) + .then(r => { if (!r.submitted) log(`answer_interaction ${msg.interactionId} not submitted`); }) + .catch(err => log(`answer_interaction failed: ${err?.message ?? err}`)); + } else { + log(`answer_interaction ignored — no live backend / adapter lacks writeInteractionAnswer`); + } + break; + } + case 'coco_drive_picker': { void driveCocoPicker(msg.navKeys, msg.needsReviewSubmit, msg.comment); break; diff --git a/test/async-trigger-state.test.ts b/test/async-trigger-state.test.ts index e5b6c9e44..36fcc7c66 100644 --- a/test/async-trigger-state.test.ts +++ b/test/async-trigger-state.test.ts @@ -66,6 +66,57 @@ describe('decideAsyncOwnership — fail-closed cross-bot isolation (P1-1)', () = }); }); +describe('resolveAsyncTriggerState — awaiting_input (#2)', () => { + const interaction = { + interactionId: 'cai_s1_1', turnId: 'trg_a', kind: 'clarification' as const, question: 'Which env?', + }; + + it('live session holding an interaction → awaiting_input, carries interaction (with turnId)', () => { + const r = resolveAsyncTriggerState({ + sessionId: 's1', liveActive: true, storedStatus: 'open', awaiting: interaction, + }); + expect(r.state).toBe('awaiting_input'); + expect(r.interaction).toEqual(interaction); + expect(r.interaction?.turnId).toBe('trg_a'); + }); + + it('awaiting takes precedence over plain running', () => { + const r = resolveAsyncTriggerState({ + sessionId: 's1', liveActive: true, storedStatus: 'open', + memResult: { status: 'pending' }, memTriggerId: 't', awaiting: interaction, + }); + expect(r.state).toBe('awaiting_input'); + }); + + it('completed still wins over awaiting (turn finished)', () => { + const r = resolveAsyncTriggerState({ + sessionId: 's1', liveActive: true, storedStatus: 'open', + memResult: { status: 'completed', content: 'done', completedAt: 1 }, memTriggerId: 't', + awaiting: interaction, + }); + expect(r.state).toBe('completed'); + expect(r.interaction).toBeUndefined(); + }); + + it('closed session ignores a stale awaiting → failed, not awaiting_input', () => { + const r = resolveAsyncTriggerState({ + sessionId: 's1', liveActive: false, storedStatus: 'closed', awaiting: interaction, + }); + expect(r.state).toBe('failed'); + }); + + it('authentication interaction passes authChallenge through', () => { + const auth = { + interactionId: 'cai_s1_2', kind: 'authentication' as const, question: 'Log in', + authChallenge: { links: [{ url: 'https://login', label: 'Sign in' }], userCode: 'ABC-123' }, + }; + const r = resolveAsyncTriggerState({ sessionId: 's1', liveActive: true, storedStatus: 'open', awaiting: auth }); + expect(r.state).toBe('awaiting_input'); + expect(r.interaction?.authChallenge?.links[0].url).toBe('https://login'); + expect(r.interaction?.authChallenge?.userCode).toBe('ABC-123'); + }); +}); + describe('resolveAsyncTriggerState — completed', () => { it('from live in-memory result', () => { const r = resolveAsyncTriggerState({ @@ -82,6 +133,30 @@ describe('resolveAsyncTriggerState — completed', () => { expect(r.triggerId).toBe('trg_a'); }); + it('emits usage on completed when provided, omits it when absent', () => { + const withUsage = resolveAsyncTriggerState({ + sessionId: 's1', liveActive: true, storedStatus: 'open', + memResult: { status: 'completed', content: 'x', completedAt: 1 }, memTriggerId: 't', + usage: { inputTokens: 10, outputTokens: 20, cacheReadTokens: 5, cacheCreateTokens: 2 }, + }); + expect(withUsage.usage).toEqual({ inputTokens: 10, outputTokens: 20, cacheReadTokens: 5, cacheCreateTokens: 2 }); + + const noUsage = resolveAsyncTriggerState({ + sessionId: 's1', liveActive: true, storedStatus: 'open', + memResult: { status: 'completed', content: 'x', completedAt: 1 }, memTriggerId: 't', + }); + expect(noUsage.usage).toBeUndefined(); + }); + + it('usage is not emitted on non-completed states', () => { + const running = resolveAsyncTriggerState({ + sessionId: 's1', liveActive: true, storedStatus: 'open', + usage: { inputTokens: 10, outputTokens: 20, cacheReadTokens: 5, cacheCreateTokens: 2 }, + }); + expect(running.state).toBe('running'); + expect(running.usage).toBeUndefined(); + }); + it('rebuilt from durable result after restart (no live session)', () => { // Simulates daemon restart: no live ds, in-memory Map gone, but the // session record is closed and the durable result says completed. diff --git a/test/cost-calculator.test.ts b/test/cost-calculator.test.ts index df914a290..e0c93e469 100644 --- a/test/cost-calculator.test.ts +++ b/test/cost-calculator.test.ts @@ -500,6 +500,25 @@ describe('getSessionTokenUsage', () => { expect(findCodexRolloutBySessionId).toHaveBeenCalledWith('codex-sid'); }); + it('resolves codex-app via the codex rollout fold (B-mode usage, not null)', () => { + // Regression for the trigger-result #3 gap: codex-app must resolve the codex + // rollout so usage is real, not a silent null on the B-mode target. + vi.mocked(findCodexSessionIdByBotmuxSessionId).mockReturnValue('codex-sid'); + vi.mocked(findCodexRolloutBySessionId).mockReturnValue('/home/testuser/.codex/sessions/rollout-codex-sid.jsonl'); + setupJsonl(JSON.stringify({ + type: 'event_msg', + payload: { + type: 'token_count', + info: { total_token_usage: { input_tokens: 120, cached_input_tokens: 20, output_tokens: 40 } }, + }, + })); + + const usage = getSessionTokenUsage({ cliId: 'codex-app', sessionId: 'botmux-sid' }); + expect(usage).not.toBeNull(); + expect(usage).toMatchObject({ inputTokens: 100, outputTokens: 40, cacheReadTokens: 20, cacheCreateTokens: 0 }); + expect(findCodexRolloutBySessionId).toHaveBeenCalledWith('codex-sid'); + }); + it('reports TraeX rollouts via the codex fold, capturing the turn_context model', () => { vi.mocked(findTraexRolloutBySessionId).mockReturnValue('/home/testuser/.trae/cli/sessions/2026/06/30/rollout-traex-sid.jsonl'); // Real TRAE rollout shapes: codex-format turn_context carries the model; diff --git a/test/trigger-api.test.ts b/test/trigger-api.test.ts index 9764a2020..2d53c4d86 100644 --- a/test/trigger-api.test.ts +++ b/test/trigger-api.test.ts @@ -104,6 +104,30 @@ 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' }; + const v = validateTriggerRequest(req); + expect(v.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 }; + const v = validateTriggerRequest(req); + expect(v.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');