diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 289f8ac2..e4e0e2a5 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -50,7 +50,7 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI - `codex tasks [--view supervisor|full] [--queue id] [--status succeeded|running|queued|failed|canceled|judging|retry_wait[,..]] [--unread|--unread-only] [--limit N] [--before-id id]` 通过同一私有代理输出渐进式披露视图。默认 `supervisor` 是低噪声指挥官视图,只返回 `activeRunning`、`running`、`completedUnread`、`recentCompleted`、`queued`、`activity`、`commanderConcurrency` 和 `executionDiagnostics` 的紧凑行;`activeRunning.count` 是 running+judging 的状态计数,`exact=true` 时来自 queue summary counts,`running.returned` 和 `activeRunning.rowPage.returned` 只是本次返回的紧凑行数。`commanderConcurrency.activeRunnerCount` 是并发策略应使用的 active/running 计数,等于 `activity.effectiveActiveTaskCount`;15 并发策略按 `15 - activeRunnerCount` 计算剩余窗口。`commanderConcurrency.splitBrainDisposition=live-count-as-active` 表示 split-brain 有 fresh heartbeat 证据,应继续监督并计入 active;`interventionRequired=true` 才提示介入。prompt/body 只给短预览和原始字符数,`running`/`completedUnread`/`queued` 默认只返回一个有界小页并通过 section `commands.next` 继续分页,`recentCompleted` 默认限量且不重复 `completedUnread` 未读终态,不嵌入完整 Trace、final response 或全量 overview。`--limit` 在 supervisor 中主要是扫描/分页预算,不是返回几十条肥行的开关;CLI 安全上限是 100,输出会在 `filters.requestedLimit`、`filters.effectiveLimit`、`filters.limitCapped` 和 `disclosure.limitPolicy` 说明显式请求是否被 capped;底层 overview 拉取预算独立显示在 `source.requestedLimit` / `source.effectiveLimit`,所以 `--limit 260` 应显示 requested=260、effective=100、source requested/effective=200,而不是只露出一个含糊的 `limit`。`--unread` 是 `--unread-only` 的别名,必须只保留未读终态;`--status` 必须真实过滤支持的状态,未知参数或未知状态必须结构化失败。需要更详细当前页任务行时显式使用 `--view full` 或 `--full`,仍受 `--limit` 和 `--before-id` 分页约束。 - `codex task --trace --tail|--from-start|--after-seq N|--before-seq N --limit N` 按页拉取 Code Queue 的逻辑 trace;响应会返回 `nextAfterSeq`、`previousBeforeSeq`、`hasMore`、`hasBefore` 和下一页/上一页命令,默认 `--trace` 取最新一页,且仍以分页 trace 为主;需要完整 prompt/最终 response 时加 `--full`,需要详细 task 摘要时加 `--detail`。 - `codex output --tail|--from-start|--after-seq N|--before-seq N --limit N [--full-text]` 按原始 output seq 分页读取底层记录;当 trace 行提示 `commandOmittedLines`、`bodyOmittedLines` 或 `rawSeqs` 时,用该命令按 seq 补取信息。默认是低噪声 raw-output 摘要:即使传入很大的 `--limit`,非 `--full-text` 也会限制返回行数和单条文本预览,并在 `disclosure.limitCapped`、`requestedLimit`、`effectiveLimit` 和 `commands.fullText` 中说明如何继续展开;显式 `--full-text` 才返回该页全文。 -- `codex read ` 在人工审阅后标记单个终态任务已读;列表、overview 和 supervisor 视图只返回这个命令字段,不得自动执行,也不得批量清空未读状态。 +- `codex read ` 在人工审阅后标记单个终态任务已读,并在同一次响应中返回稳定任务身份、执行元数据、终态 attempt 摘要、最后错误或 judge 信息和最终 response,避免标记已读后还要额外 drill-down 才能确认结果。该命令不返回完整 prompt、tool logs 或 feedback prompt,只返回字符数、计数和 `codex task/detail/trace/output` 渐进披露命令;列表、overview 和 supervisor 视图只返回这个命令字段,不得自动执行,也不得批量清空未读状态。 - `codex dev-ready` 查询 Code Queue `/api/dev-ready` 并返回有界 readiness 摘要,包括工具、Docker、Codex config、SSH 和 `devReady.skills`。`devReady.skills` 只暴露 `UNIDESK_SKILLS_PATH`、是否存在、是否只读、skillCount、`cli-spec` 是否可见和修复建议,不输出宿主 auth/token 文件内容。 - `codex judge --attempt N [--dry-run] [--include-prompt]` 通过 Code Queue 私有代理按指定 attempt 单步复现 judge;这是执行面诊断入口,仍依赖 D601 scheduler/runner 侧的真实 judge builder、MiniMax 调用路径和执行环境。默认会真实调用 MiniMax,`--dry-run` 只返回 prompt/payload 大小、attempt 窗口和重建来源诊断,`--include-prompt` 仅用于本地深度排查。 - `codex steer [prompt|--prompt-file path|--prompt-stdin] [--dry-run] [--no-retry|--retry-attempts N]` 通过 Code Queue 私有代理向正在运行的 task 注入纠偏提示,正式替代底层 `microservice proxy code-queue /api/tasks//steer` 调用。prompt 必须且只能来自位置参数、文件或 stdin 之一;`--dry-run` 只输出 `method`、`path`、`stableProxyPath`、retry policy、prompt 字符数、截断预览和 raw proxy 等价命令,不触碰运行中 session,也不得泄露超长 prompt 全文。真实执行是写入操作,成功只返回 `accepted=true`、task id、prompt 字符数、`promptOmitted=true`、有界 task/queue 确认、attempt summary 和后续查看命令,不回显 prompt 或完整 task state;路径固定为 `/api/microservices/code-queue/proxy/api/tasks//steer`,只能作用于 D601 scheduler 上存在 active steerable turn 的 running task。默认对 `stable-proxy-failed` 和 `backend-core-unreachable` 这类 retryable control-plane failures 做一次有界重试;`--retry-attempts N` 最大为 3,`--retry-delay-ms N` 最大为 5000,`--no-retry` 用于复现单次失败。 diff --git a/scripts/code-queue-cli-read-terminal-contract-test.ts b/scripts/code-queue-cli-read-terminal-contract-test.ts new file mode 100644 index 00000000..bd5f01c5 --- /dev/null +++ b/scripts/code-queue-cli-read-terminal-contract-test.ts @@ -0,0 +1,242 @@ +import { codexReadTaskForTest } from "./src/code-queue"; + +type JsonRecord = Record; +type FetchCall = { path: string; init?: { method?: string; body?: unknown } }; + +const promptSecret = "PROMPT_BODY_SHOULD_NOT_LEAK_FROM_CODEX_READ"; +const toolSecret = "TOOL_LOG_SHOULD_NOT_LEAK_FROM_CODEX_READ"; +const feedbackSecret = "FEEDBACK_PROMPT_SHOULD_NOT_LEAK_FROM_CODEX_READ"; +const referenceSecret = "REFERENCE_INJECTION_BASE_PROMPT_SHOULD_NOT_LEAK_FROM_CODEX_READ"; + +function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { + if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); +} + +function asRecord(value: unknown, label: string): JsonRecord { + assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, value); + return value as JsonRecord; +} + +function asArray(value: unknown, label: string): unknown[] { + assertCondition(Array.isArray(value), `${label} must be an array`, value); + return value as unknown[]; +} + +function taskIdFromPath(path: string): string { + const match = path.match(/\/api\/tasks\/([^/?]+)/u); + return decodeURIComponent(match?.[1] ?? "unknown"); +} + +function summaryFor(taskId: string, status: "succeeded" | "failed"): JsonRecord { + const failed = status === "failed"; + const finalResponse = failed + ? "Failure summary: tests did not pass, but the runner reported the exact failing command." + : "Final response: implemented the terminal read fix and validated the focused contract."; + return { + id: taskId, + queueId: "hwlab", + status, + providerId: "D601", + executionMode: "container", + model: "gpt-5.5", + agentPort: "codex", + cwd: "/workspace/unidesk", + reasoningEffort: "medium", + maxAttempts: 2, + currentAttempt: failed ? 2 : 1, + currentMode: failed ? "retry" : "initial", + judgeFailCount: failed ? 2 : 0, + judgeFailRetryLimit: 3, + codexThreadId: `thread_${taskId}`, + activeTurnId: null, + createdAt: "2026-05-22T00:00:00.000Z", + startedAt: "2026-05-22T00:01:00.000Z", + updatedAt: "2026-05-22T00:03:00.000Z", + finishedAt: "2026-05-22T00:03:00.000Z", + timing: { totalMs: 120000 }, + initialPrompt: `${promptSecret}\nPlease fix the task.`, + prompt: `${promptSecret}\nPlease fix the task.`, + referenceTaskIds: ["codex_reference_task"], + referenceInjection: { + version: 2, + injectedAt: "2026-05-22T00:00:30.000Z", + basePrompt: referenceSecret, + directReferenceTaskIds: ["codex_reference_task"], + maxRounds: 3, + truncated: false, + itemCount: 1, + items: [{ + round: 1, + roundIndex: 0, + taskId: "codex_reference_task", + viaTaskId: null, + status: "succeeded", + providerId: "D601", + executionMode: "container", + model: "gpt-5.5", + cwd: "/workspace/unidesk", + createdAt: "2026-05-21T00:00:00.000Z", + updatedAt: "2026-05-21T00:03:00.000Z", + promptChars: 9999, + finalResponseChars: 1234, + finalResponseAt: "2026-05-21T00:03:00.000Z", + finalResponseSource: "finalResponse", + referenceTaskIds: [], + cliHint: "bun scripts/cli.ts codex task codex_reference_task", + }], + }, + lastAssistantMessage: { + at: "2026-05-22T00:03:00.000Z", + seq: 41, + source: "finalResponse", + text: finalResponse, + }, + toolSummary: { + count: 3, + returned: 1, + limit: 1, + truncated: true, + items: [{ seq: 39, kind: "ran", outputPreview: toolSecret }], + }, + attempts: [ + { + index: failed ? 2 : 1, + mode: failed ? "retry" : "initial", + terminalStatus: failed ? "failed" : "completed", + appServerExitCode: failed ? 1 : 0, + appServerSignal: null, + error: failed ? "focused contract failed" : null, + stderrTail: failed ? "bun scripts/code-queue-cli-read-terminal-contract-test.ts failed" : "", + startedAt: "2026-05-22T00:01:00.000Z", + finishedAt: "2026-05-22T00:03:00.000Z", + outputStartSeq: 1, + outputEndSeq: 42, + finalResponse, + finalResponsePreview: finalResponse, + finalResponseChars: finalResponse.length, + feedbackPromptPreview: feedbackSecret, + judge: failed ? { decision: "fail", confidence: 0.88, reason: "contract failed" } : { decision: "complete", confidence: 0.97, reason: "verified" }, + runnerErrorClassification: failed ? { class: "test-failure", retryable: false } : null, + }, + ], + lastJudge: failed ? { decision: "fail", confidence: 0.88, reason: "contract failed", source: "minimax" } : { decision: "complete", confidence: 0.97, reason: "verified", source: "minimax" }, + lastError: failed ? "focused contract failed" : null, + cancelRequested: false, + transcriptCount: 12, + transcriptMaxSeq: 42, + outputCount: 42, + retainedOutputCount: 20, + outputMaxSeq: 42, + eventCount: 5, + }; +} + +function readTerminalFixture(calls: FetchCall[]): (path: string, init?: { method?: string; body?: unknown }) => unknown { + return (path, init) => { + calls.push({ path, init }); + const taskId = taskIdFromPath(path); + const status = taskId.includes("failed") ? "failed" : "succeeded"; + if (path.includes("/summary")) { + return { ok: true, status: 200, body: { ok: true, summary: summaryFor(taskId, status) } }; + } + if (path.includes("/read")) { + return { + ok: true, + status: 200, + body: { + ok: true, + task: { + id: taskId, + queueId: "hwlab", + status, + readAt: "2026-05-22T00:04:00.000Z", + terminalUnread: false, + }, + queue: { counts: { [status]: 1 }, unreadTerminal: 0 }, + }, + }; + } + throw new Error(`unexpected path ${path}`); + }; +} + +function missingTaskFixture(calls: FetchCall[]): (path: string, init?: { method?: string; body?: unknown }) => unknown { + return (path, init) => { + calls.push({ path, init }); + return { ok: true, status: 404, body: { ok: false, error: "task not found" } }; + }; +} + +function assertTerminalReadShape(result: unknown, taskId: string, status: "succeeded" | "failed"): void { + const data = asRecord(result, "result"); + const task = asRecord(data.task, "task"); + const finalResponse = asRecord(task.finalResponse, "finalResponse"); + const attempts = asRecord(task.attempts, "attempts"); + const lastAttempt = asRecord(attempts.lastAttempt, "lastAttempt"); + const read = asRecord(data.read, "read"); + const disclosure = asRecord(task.disclosure, "disclosure"); + const body = JSON.stringify(result); + + assertCondition(task.id === taskId, "read result must preserve task id", task); + assertCondition(task.queueId === "hwlab", "read result must preserve queue id", task); + assertCondition(task.status === status, "read result must preserve terminal status", task); + assertCondition(task.model === "gpt-5.5" && task.providerId === "D601" && task.cwd === "/workspace/unidesk", "read result must preserve stable execution metadata", task); + assertCondition(task.createdAt === "2026-05-22T00:00:00.000Z", "read result must include createdAt", task); + assertCondition(task.startedAt === "2026-05-22T00:01:00.000Z", "read result must include startedAt", task); + assertCondition(task.updatedAt === "2026-05-22T00:03:00.000Z", "read result must include updatedAt", task); + assertCondition(task.finishedAt === "2026-05-22T00:03:00.000Z", "read result must include finishedAt", task); + assertCondition(task.readAt === "2026-05-22T00:04:00.000Z" && task.terminalUnread === false, "read result must preserve read acknowledgement", task); + assertCondition(read.marked === true && read.terminalUnread === false, "top-level read acknowledgement must be stable", read); + assertCondition(String(finalResponse.text ?? "").includes(status === "failed" ? "Failure summary" : "Final response"), "read result must include final response text", finalResponse); + assertCondition(finalResponse.chars === String(finalResponse.text ?? "").length && finalResponse.truncated === false, "read result must include bounded final response preview metadata", finalResponse); + assertCondition(lastAttempt.terminalStatus === (status === "failed" ? "failed" : "completed"), "read result must include terminal attempt summary", lastAttempt); + assertCondition(disclosure.promptIncluded === false && disclosure.toolLogsIncluded === false && disclosure.finalResponseIncluded === true, "read disclosure policy must be explicit", disclosure); + const commands = asRecord(task.commands, "task.commands"); + assertCondition(String(commands.detail ?? "") === `bun scripts/cli.ts codex task ${taskId} --detail`, "read result must include detail drill-down command", commands); + assertCondition(String(commands.trace ?? "").includes(`codex task ${taskId} --trace`), "read result must include trace drill-down command", commands); + assertCondition(String(commands.output ?? "").includes(`codex output ${taskId}`), "read result must include output drill-down command", commands); + assertCondition(!body.includes(promptSecret), "read result must not leak prompt body", body); + assertCondition(!body.includes(toolSecret), "read result must not leak tool logs", body); + assertCondition(!body.includes(feedbackSecret), "read result must not leak feedback prompt body", body); + assertCondition(!body.includes(referenceSecret), "read result must not leak reference injection base prompt", body); + if (status === "failed") { + assertCondition(task.lastError === "focused contract failed", "failed read must include lastError", task); + assertCondition(String(asRecord(lastAttempt.stderrTail, "stderrTail").text ?? "").includes("contract-test"), "failed read must include stderr tail", lastAttempt); + assertCondition(asRecord(lastAttempt.runnerErrorClassification, "runnerErrorClassification").class === "test-failure", "failed read must include runner error classification", lastAttempt); + } +} + +function run(): JsonRecord { + const succeededCalls: FetchCall[] = []; + const succeeded = codexReadTaskForTest("codex_succeeded_terminal", readTerminalFixture(succeededCalls)); + assertTerminalReadShape(succeeded, "codex_succeeded_terminal", "succeeded"); + assertCondition(succeededCalls.length === 2, "succeeded read must fetch summary then mark read", succeededCalls); + assertCondition(succeededCalls[0]?.path.includes("/summary?toolLimit=3") && succeededCalls[1]?.path.includes("/read"), "succeeded read call order must preserve body before mutation", succeededCalls); + assertCondition(succeededCalls[1]?.init?.method === "POST", "read mutation must use POST", succeededCalls); + + const failedCalls: FetchCall[] = []; + const failed = codexReadTaskForTest("codex_failed_terminal", readTerminalFixture(failedCalls)); + assertTerminalReadShape(failed, "codex_failed_terminal", "failed"); + + const missingCalls: FetchCall[] = []; + let missingError: Error | null = null; + try { + codexReadTaskForTest("codex_missing_terminal", missingTaskFixture(missingCalls)); + } catch (error) { + missingError = error instanceof Error ? error : new Error(String(error)); + } + assertCondition(missingError !== null && missingError.message.includes("task not found"), "missing task must fail with task not found", missingError?.message); + assertCondition(missingCalls.length === 1 && missingCalls[0]?.path.includes("/summary"), "missing task must not issue read mutation after failed lookup", missingCalls); + + return { + ok: true, + checks: [ + "succeeded terminal read returns status, queue, timestamps, final response preview, and drill-down commands from summary before marking read", + "failed terminal read returns final response, lastError, stderr tail, and runner classification", + "missing task fails before issuing a read mutation", + "prompt, tool logs, and feedback prompts stay behind progressive disclosure commands", + ], + }; +} + +process.stdout.write(`${JSON.stringify(run(), null, 2)}\n`); diff --git a/scripts/src/check.ts b/scripts/src/check.ts index cc702d29..d084c647 100644 --- a/scripts/src/check.ts +++ b/scripts/src/check.ts @@ -33,6 +33,7 @@ const syntaxFiles = [ "scripts/code-queue-cli-disclosure-contract-test.ts", "scripts/code-queue-cli-steer-test.ts", "scripts/code-queue-cli-submit-prompt-contract-test.ts", + "scripts/code-queue-cli-read-terminal-contract-test.ts", "scripts/code-queue-gh-auth-redaction-contract-test.ts", "scripts/microservice-health-output-contract-test.ts", "scripts/code-queue-supervisor-disclosure-contract-test.ts", @@ -311,6 +312,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default fileItem("scripts/code-queue-runner-skills-contract-test.ts"), fileItem("scripts/code-queue-cli-disclosure-contract-test.ts"), fileItem("scripts/code-queue-cli-steer-test.ts"), + fileItem("scripts/code-queue-cli-read-terminal-contract-test.ts"), fileItem("scripts/code-queue-submit-routing-contract-test.ts"), fileItem("scripts/code-queue-gh-auth-redaction-contract-test.ts"), fileItem("scripts/code-queue-supervisor-disclosure-contract-test.ts"), @@ -352,6 +354,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default items.push(commandItem("code-queue:runner-skills-contract", ["bun", "scripts/code-queue-runner-skills-contract-test.ts"], 30_000)); items.push(commandItem("code-queue:cli-disclosure-contract", ["bun", "scripts/code-queue-cli-disclosure-contract-test.ts"], 30_000)); items.push(commandItem("code-queue:cli-steer-contract", ["bun", "scripts/code-queue-cli-steer-test.ts"], 30_000)); + items.push(commandItem("code-queue:read-terminal-contract", ["bun", "scripts/code-queue-cli-read-terminal-contract-test.ts"], 30_000)); items.push(commandItem("code-queue:submit-prompt-contract", ["bun", "scripts/code-queue-cli-submit-prompt-contract-test.ts"], 30_000)); items.push(commandItem("code-queue:submit-routing-contract", ["bun", "scripts/code-queue-submit-routing-contract-test.ts"], 30_000)); items.push(commandItem("code-queue:gh-auth-redaction-contract", ["bun", "scripts/code-queue-gh-auth-redaction-contract-test.ts"], 30_000)); @@ -383,6 +386,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default items.push(skippedItem("code-queue:runner-skills-contract", "Code Queue runner skill availability contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("code-queue:cli-disclosure-contract", "Code Queue CLI disclosure/noise contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("code-queue:cli-steer-contract", "Code Queue steer CLI contract is opt-in with script checks", "--scripts-typecheck or --full")); + items.push(skippedItem("code-queue:read-terminal-contract", "Code Queue terminal read contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("code-queue:submit-prompt-contract", "Code Queue submit prompt contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("code-queue:submit-routing-contract", "Code Queue submit routing contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("code-queue:gh-auth-redaction-contract", "Code Queue GitHub auth output redaction contract is opt-in with script checks", "--scripts-typecheck or --full")); diff --git a/scripts/src/code-queue.ts b/scripts/src/code-queue.ts index b5020aed..0abff87b 100644 --- a/scripts/src/code-queue.ts +++ b/scripts/src/code-queue.ts @@ -3415,19 +3415,192 @@ export function compactSubmitSuccessResponseForTest(body: Record | null { + const record = asRecord(value); + if (record === null) return null; return { - upstream: response.upstream, - task: compactTaskMutationResponse(response.body.task), + index: record.index ?? null, + synthetic: record.synthetic ?? false, + label: record.label ?? null, + mode: record.mode ?? null, + terminalStatus: record.terminalStatus ?? null, + appServerExitCode: record.appServerExitCode ?? null, + appServerSignal: record.appServerSignal ?? null, + transportClosedBeforeTerminal: record.transportClosedBeforeTerminal ?? null, + error: record.error ?? null, + stderrTail: textView(asString(record.stderrTail), false, 1200), + startedAt: record.startedAt ?? null, + finishedAt: record.finishedAt ?? null, + startSeq: record.startSeq ?? record.outputStartSeq ?? null, + endSeq: record.endSeq ?? record.outputEndSeq ?? null, + execution: record.execution ?? null, + finalResponse: textView(asString(record.finalResponsePreview ?? record.finalResponse), false, 3000), + judge: record.judge ?? null, + runnerErrorClassification: record.runnerErrorClassification ?? null, + }; +} + +function compactTerminalReadFinalResponse(summary: Record, readTask: Record): Record { + const lastAssistantMessage = asRecord(summary.lastAssistantMessage); + const text = asString(lastAssistantMessage?.text ?? summary.finalResponse ?? readTask.finalResponse); + return { + at: lastAssistantMessage?.at ?? summary.finishedAt ?? readTask.finishedAt ?? null, + seq: lastAssistantMessage?.seq ?? null, + source: lastAssistantMessage?.source ?? (text.trim().length > 0 ? "finalResponse" : "none"), + ...textView(text, false, 6000), + }; +} + +function compactReadReferenceInjection(value: unknown): Record | null { + const record = asRecord(value); + if (record === null) return null; + const items = asArray(record.items); + const returnedItems = items.slice(0, 12); + return { + version: record.version ?? null, + injectedAt: record.injectedAt ?? null, + itemCount: record.itemCount ?? null, + directReferenceTaskIds: record.directReferenceTaskIds ?? [], + maxRounds: record.maxRounds ?? null, + truncated: record.truncated ?? null, + itemsReturned: returnedItems.length, + itemsTruncated: items.length > returnedItems.length, + items: returnedItems.map((item) => { + const itemRecord = asRecord(item) ?? {}; + return { + round: itemRecord.round ?? null, + roundIndex: itemRecord.roundIndex ?? null, + taskId: itemRecord.taskId ?? null, + viaTaskId: itemRecord.viaTaskId ?? null, + status: itemRecord.status ?? null, + providerId: itemRecord.providerId ?? null, + executionMode: itemRecord.executionMode ?? null, + model: itemRecord.model ?? null, + cwd: itemRecord.cwd ?? null, + createdAt: itemRecord.createdAt ?? null, + updatedAt: itemRecord.updatedAt ?? null, + promptChars: itemRecord.promptChars ?? null, + finalResponseChars: itemRecord.finalResponseChars ?? null, + finalResponseAt: itemRecord.finalResponseAt ?? null, + finalResponseSource: itemRecord.finalResponseSource ?? null, + referenceTaskIds: itemRecord.referenceTaskIds ?? [], + cliHint: itemRecord.cliHint ?? null, + }; + }), + }; +} + +function compactTerminalReadTask(summary: unknown, readTask: unknown, taskId: string): Record { + const summaryRecord = asRecord(summary) ?? {}; + const readRecord = asRecord(readTask) ?? {}; + const id = asString(summaryRecord.id ?? readRecord.id) || taskId; + const attempts = asArray(summaryRecord.attempts); + const lastAttempt = compactTerminalReadAttempt(attempts.at(-1)); + const toolSummary = asRecord(summaryRecord.toolSummary) ?? {}; + const readAt = readRecord.readAt ?? summaryRecord.readAt ?? null; + const referenceInjection = compactReadReferenceInjection(summaryRecord.referenceInjection ?? readRecord.referenceInjectionSummary); + return { + id, + queueId: summaryRecord.queueId ?? readRecord.queueId ?? null, + status: summaryRecord.status ?? readRecord.status ?? null, + terminalUnread: false, + readAt, + providerId: summaryRecord.providerId ?? readRecord.providerId ?? null, + executionMode: summaryRecord.executionMode ?? readRecord.executionMode ?? null, + executionModeInfo: summaryRecord.executionModeInfo ?? readRecord.executionModeInfo ?? null, + model: summaryRecord.model ?? readRecord.model ?? null, + agentPort: summaryRecord.agentPort ?? readRecord.agentPort ?? null, + agentPortInfo: summaryRecord.agentPortInfo ?? readRecord.agentPortInfo ?? null, + cwd: summaryRecord.cwd ?? readRecord.cwd ?? null, + reasoningEffort: summaryRecord.reasoningEffort ?? readRecord.reasoningEffort ?? null, + maxAttempts: summaryRecord.maxAttempts ?? readRecord.maxAttempts ?? null, + attempts: { + currentAttempt: summaryRecord.currentAttempt ?? readRecord.currentAttempt ?? null, + maxAttempts: summaryRecord.maxAttempts ?? readRecord.maxAttempts ?? null, + currentMode: summaryRecord.currentMode ?? readRecord.currentMode ?? null, + judgeFailCount: summaryRecord.judgeFailCount ?? readRecord.judgeFailCount ?? null, + judgeFailRetryLimit: summaryRecord.judgeFailRetryLimit ?? readRecord.judgeFailRetryLimit ?? null, + count: attempts.length, + lastAttempt, + }, + thread: { + codexThreadId: summaryRecord.codexThreadId ?? readRecord.codexThreadId ?? null, + activeTurnId: summaryRecord.activeTurnId ?? readRecord.activeTurnId ?? null, + cancelRequested: summaryRecord.cancelRequested ?? readRecord.cancelRequested ?? null, + }, + timing: summaryRecord.timing ?? readRecord.timing ?? null, + createdAt: summaryRecord.createdAt ?? readRecord.createdAt ?? null, + startedAt: summaryRecord.startedAt ?? readRecord.startedAt ?? null, + updatedAt: summaryRecord.updatedAt ?? readRecord.updatedAt ?? null, + finishedAt: summaryRecord.finishedAt ?? readRecord.finishedAt ?? null, + referenceTaskIds: summaryRecord.referenceTaskIds ?? readRecord.referenceTaskIds ?? [], + referenceInjection, + finalResponse: compactTerminalReadFinalResponse(summaryRecord, readRecord), + lastError: summaryRecord.lastError ?? readRecord.lastError ?? lastAttempt?.error ?? null, + lastJudge: summaryRecord.lastJudge ?? readRecord.lastJudge ?? null, + counts: { + transcript: summaryRecord.transcriptCount ?? readRecord.transcriptCount ?? null, + transcriptMaxSeq: summaryRecord.transcriptMaxSeq ?? readRecord.transcriptMaxSeq ?? null, + output: summaryRecord.outputCount ?? readRecord.outputCount ?? null, + retainedOutput: summaryRecord.retainedOutputCount ?? readRecord.retainedOutputCount ?? null, + outputMaxSeq: summaryRecord.outputMaxSeq ?? readRecord.outputMaxSeq ?? null, + events: summaryRecord.eventCount ?? readRecord.eventCount ?? null, + tools: toolSummary.count ?? null, + }, + disclosure: { + mode: "terminal-read", + promptIncluded: false, + toolLogsIncluded: false, + finalResponseIncluded: true, + promptChars: asString(summaryRecord.initialPrompt ?? summaryRecord.prompt).length || (summaryRecord.promptChars ?? null), + toolCount: toolSummary.count ?? null, + policy: "read returns terminal metadata and final response; prompt and tool logs remain behind explicit progressive drill-down commands", + }, + commands: { + show: `bun scripts/cli.ts codex task ${id}`, + detail: `bun scripts/cli.ts codex task ${id} --detail`, + full: `bun scripts/cli.ts codex task ${id} --full`, + trace: `bun scripts/cli.ts codex task ${id} --trace --tail --limit ${defaultTraceLimit}`, + output: `bun scripts/cli.ts codex output ${id} --tail --limit ${defaultOutputLimit}`, + }, + }; +} + +function codexReadTaskWithFetcher(taskId: string, fetcher: CodexResponseFetcher): unknown { + const summaryResponse = unwrapCodexResponse(fetcher(codeQueueProxyPath(`/api/tasks/${encodeURIComponent(taskId)}/summary${queryString({ toolLimit: defaultToolLimit })}`))); + const response = unwrapCodexResponse(fetcher(codeQueueProxyPath(`/api/tasks/${encodeURIComponent(taskId)}/read`), { method: "POST", body: {} })); + const readTask = asRecord(response.body.task) ?? {}; + const terminalUnread = readTask.terminalUnread ?? readTask.unreadTerminal ?? false; + return { + upstream: { + summary: summaryResponse.upstream, + read: response.upstream, + }, + read: { + marked: true, + readAt: readTask.readAt ?? null, + terminalUnread, + }, + task: compactTerminalReadTask(summaryResponse.body.summary, response.body.task, taskId), queue: compactQueueMutationSummary(response.body.queue), commands: { show: `bun scripts/cli.ts codex task ${taskId}`, + detail: `bun scripts/cli.ts codex task ${taskId} --detail`, + trace: `bun scripts/cli.ts codex task ${taskId} --trace --tail --limit ${defaultTraceLimit}`, + output: `bun scripts/cli.ts codex output ${taskId} --tail --limit ${defaultOutputLimit}`, unread: `bun scripts/cli.ts codex tasks --unread --limit ${defaultTasksLimit}`, }, }; } +function codexReadTask(taskId: string): unknown { + return codexReadTaskWithFetcher(taskId, coreInternalFetch); +} + +export function codexReadTaskForTest(taskId: string, fetcher: CodexResponseFetcher): unknown { + return codexReadTaskWithFetcher(taskId, fetcher); +} + function compactQueueMutationSummary(value: unknown): Record | null { const record = asRecord(value); if (record === null) return null; diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 6c85fb58..f9954625 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -58,7 +58,7 @@ export function rootHelp(): unknown { { command: "codex task [--detail] [--trace --tail|--from-start|--after-seq N|--before-seq N --limit N] [--full]", description: "Fetch the bounded review view by default; --detail is still capped, while --full/trace/output explicitly expand evidence." }, { command: "codex tasks [--view supervisor|full] [--queue id] [--status status[,status]] [--unread|--unread-only] [--limit N] [--before-id id]", description: "Show the low-noise supervisor view by default: compact task rows, tiny local sections, activity counts, diagnostics, and drill-down commands; use --view full for detailed rows." }, { command: "codex output [--tail|--from-start|--after-seq N|--before-seq N --limit N] [--full-text]", description: "Fetch paged raw Code Queue output records; default caps large limits/text previews, --full-text explicitly expands one seq window." }, - { command: "codex read ", description: "Mark one reviewed terminal task read; never run automatically as part of listing." }, + { command: "codex read ", description: "Mark one reviewed terminal task read and return terminal metadata plus final response; prompt/tool logs stay behind drill-down commands." }, { command: "codex dev-ready", description: "Fetch execution-container readiness, including sanitized skill injection status from /api/dev-ready." }, { command: "codex judge --attempt N [--dry-run] [--include-prompt]", description: "Replay one stored Code Queue attempt through the same judge context builder and MiniMax judge call path used by the live queue worker." }, { command: "codex steer [prompt|--prompt-file path|--prompt-stdin] [--dry-run] [--no-retry|--retry-attempts N]", description: "Push a corrective prompt into a running Code Queue task; retryable tunnel aborts get bounded retry diagnostics, and real success does not echo prompt text." }, @@ -271,6 +271,10 @@ function codexHelp(): unknown { positional: "Keep positional prompt usage to short one-line smoke prompts only.", sourceRule: "Exactly one prompt source is accepted: positional prompt, --prompt-file, or --prompt-stdin.", }, + readOutput: { + default: "codex read marks a terminal task read and returns terminal metadata, final response, last error/judge, counts, and drill-down commands.", + disclosure: "Full prompt, tool logs, and feedback prompts are not printed by codex read; use codex task/detail/trace/output for progressive disclosure.", + }, examples: { stdin: [ "cat <<'PROMPT' | bun scripts/cli.ts codex submit --prompt-stdin --queue --dry-run",