From 4c7a2a9f7cdba010eddfff8b5d984105a1d9b7a3 Mon Sep 17 00:00:00 2001 From: liuhe Date: Sat, 10 Oct 2026 18:57:45 +0800 Subject: [PATCH 1/2] fix(codex): restore source-plugin and session evidence Defer optional DSH loading so Codex source-plugin collection can start independently. Recover resumed rollouts from in-window activity, keep host UI context out of task roots, retain exec wrapper activity without implying success, and preserve image-only user task boundaries after privacy sanitization. Validated 590 related tests across 33 files on Node 24.13.0/macOS. The image-boundary regression failed before the fix and passed afterward; an independent recheck found no new must-fix issues. Co-authored-by: Codex (GPT 6.1 Sol) --- scripts/agent-customize/providers/index.mjs | 14 +- scripts/session-analysis/episode-facts.mjs | 4 +- scripts/session-analysis/platforms/codex.mjs | 29 +++- .../session-analysis/privacy-safe-text.mjs | 8 +- test/cli/source-plugin-runtime.test.mjs | 42 ++++++ test/sessions/codex-evidence-window.test.mjs | 133 ++++++++++++++++++ 6 files changed, 219 insertions(+), 11 deletions(-) create mode 100644 test/cli/source-plugin-runtime.test.mjs create mode 100644 test/sessions/codex-evidence-window.test.mjs diff --git a/scripts/agent-customize/providers/index.mjs b/scripts/agent-customize/providers/index.mjs index 86afbca1..eea97cdc 100644 --- a/scripts/agent-customize/providers/index.mjs +++ b/scripts/agent-customize/providers/index.mjs @@ -2,7 +2,6 @@ import { collectClaudeCustomizeInventory } from "./claude.mjs"; import { collectCodexCustomizeInventory } from "./codex.mjs"; import { collectCopilotCustomizeInventory } from "./copilot.mjs"; import { collectCursorCustomizeInventory } from "./cursor.mjs"; -import { collectDshCustomizeInventory } from "./dsh.mjs"; import { collectGrokCustomizeInventory } from "./grok.mjs"; import { collectPiCustomizeInventory } from "./pi.mjs"; import { collectKimiCustomizeInventory } from "./kimi.mjs"; @@ -11,6 +10,19 @@ import { collectQwenCustomizeInventory } from "./qwen.mjs"; import { collectWorkbuddyCustomizeInventory } from "./workbuddy.mjs"; import { HOST_CAPABILITIES, hostIdsFor } from "../../host-support/index.mjs"; +async function collectDshCustomizeInventory(options) { + let provider; + try { + provider = await import("./dsh.mjs"); + } catch (error) { + if (error.code === "ERR_MODULE_NOT_FOUND" && /Cannot find package 'yaml'/u.test(error.message)) { + throw new Error("DSH configured-assets inventory requires the yaml runtime dependency. Run npm ci in a source checkout, or use the packaged runtime bundle.", { cause: error }); + } + throw error; + } + return provider.collectDshCustomizeInventory(options); +} + export const PROVIDER_COLLECTORS = new Map([ ["cursor", collectCursorCustomizeInventory], ["qoder", collectQoderCustomizeInventory], diff --git a/scripts/session-analysis/episode-facts.mjs b/scripts/session-analysis/episode-facts.mjs index 349b5320..6d71301d 100644 --- a/scripts/session-analysis/episode-facts.mjs +++ b/scripts/session-analysis/episode-facts.mjs @@ -880,7 +880,7 @@ function observedWorkPhase(event) { if (postOnly) return null; if (/^(?:read|readfile|grep|glob|search|find|listfiles)$/u.test(name)) return "inspect"; if (/^(?:edit|multiedit|write|notebookedit|notebookwrite|apply_patch|searchreplace)$/u.test(name)) return "change"; - if (/^(?:bash|exec_command|shell|terminal|runcommand)$/u.test(name)) return "execute"; + if (/^(?:bash|exec|exec_command|shell|terminal|runcommand)$/u.test(name)) return "execute"; return null; } @@ -913,7 +913,7 @@ function observedProjectActivity(events) { const name = normalizedToolName(event); if (!name) continue; if (/^(?:read|readfile|grep|glob|search|find|listfiles)$/u.test(name)) reads += 1; - if (/^(?:read|readfile|grep|glob|search|find|listfiles|edit|multiedit|write|notebookedit|notebookwrite|apply_patch|searchreplace|bash|exec_command|skill)$/u.test(name)) { + if (/^(?:read|readfile|grep|glob|search|find|listfiles|edit|multiedit|write|notebookedit|notebookwrite|apply_patch|searchreplace|bash|exec|exec_command|skill)$/u.test(name)) { toolCalls += 1; } } diff --git a/scripts/session-analysis/platforms/codex.mjs b/scripts/session-analysis/platforms/codex.mjs index 77fc5c4d..21b24137 100644 --- a/scripts/session-analysis/platforms/codex.mjs +++ b/scripts/session-analysis/platforms/codex.mjs @@ -42,6 +42,7 @@ import { workspaceMatchScopeFromOptions, } from "../provider-runner.mjs"; import { WORKSPACE_CWD_MATCH, classifyWorkspaceCwd } from "../workspace-match.mjs"; +import { isContextOnlyUserInput } from "../privacy-safe-text.mjs"; const DEFAULT_LIMIT = 50; @@ -681,15 +682,16 @@ function buildFacets(indexedSessions, detailedSessions, events) { }; } -async function firstJsonlRecord(filePath) { +async function firstJsonlRecord(filePath, predicate = null) { let first = null; await forEachJsonLine( filePath, (raw) => { + if (predicate && !predicate(raw)) return; first = raw; return false; }, - { maxLines: 1 }, + predicate ? {} : { maxLines: 1 }, ); return first; } @@ -862,17 +864,27 @@ export class CodexSessionAnalyzer extends SessionAnalyzer { for (const filePath of files) { const first = await firstJsonlRecord(filePath); - const timestamp = inferTimestamp(first); + let timestamp = inferTimestamp(first); const cwd = inferCwd(first); const planningScope = cwd && !isScopedWorkspaceMatch(cwd, scope) ? "user-global" : "workspace"; if (planningScope === "user-global" && !scope.includeGlobalCapabilities) { continue; } + const fallback = path.basename(filePath, ".jsonl").replace(/^rollout-\d{4}-\d{2}-\d{2}T[^-]+-/, ""); + const sessionId = inferSessionId(first, fallback); + if (scope.sessionId && sessionId !== scope.sessionId) continue; if (!withinTimeRange(timestamp, scope)) { - continue; + // The header records creation, not the last activity of a resumed session. + const activity = await firstJsonlRecord(filePath, (raw) => { + const observedAt = inferTimestamp(raw); + return inferSessionId(raw, sessionId) === sessionId + && timestampMillis(observedAt) !== null + && withinTimeRange(observedAt, scope); + }); + if (!activity) continue; + timestamp = inferTimestamp(activity); } - const fallback = path.basename(filePath, ".jsonl").replace(/^rollout-\d{4}-\d{2}-\d{2}T[^-]+-/, ""); - addSessionRef(sessions, inferSessionId(first, fallback), scope.workspace, { + addSessionRef(sessions, sessionId, scope.workspace, { kind, role: root.role, path: filePath, @@ -886,8 +898,11 @@ export class CodexSessionAnalyzer extends SessionAnalyzer { } normalizeEvent(raw, sourceRef, options = {}) { - const type = inferType(raw); const text = messageText(raw); + const rawType = inferType(raw); + const type = rawType === "user" && isContextOnlyUserInput(text) + ? "context.user-input" + : rawType; const retainsDialogueText = ["user", "assistant", "last-prompt", "UserPromptSubmit"].includes(type); const event = { sessionId: inferSessionId(raw, sourceRef.sessionId), diff --git a/scripts/session-analysis/privacy-safe-text.mjs b/scripts/session-analysis/privacy-safe-text.mjs index 279c65b3..86c723ae 100644 --- a/scripts/session-analysis/privacy-safe-text.mjs +++ b/scripts/session-analysis/privacy-safe-text.mjs @@ -1,5 +1,5 @@ const INJECTED_CONTEXT_PREFIX_RE = /^(?:#\s*AGENTS\.md instructions\b|||#\s*Files mentioned by the user:)/iu; -const INJECTED_XML_BLOCK_RE = /<(environment_context|skill|recommended_plugins|codex_internal_context|local-command-caveat|local-command-stdout|command-name|command-message|command-args)\b[^>]*>[\s\S]*?<\/\1>/giu; +const INJECTED_XML_BLOCK_RE = /<(environment_context|skill|recommended_plugins|codex_internal_context|external_codex_apps_open_page|in-app-browser-context|local-command-caveat|local-command-stdout|command-name|command-message|command-args)\b[^>]*>[\s\S]*?<\/\1>/giu; const TURN_ABORTED_BLOCK_RE = /]*>[\s\S]*?<\/turn_aborted>/giu; const EMBEDDED_TRANSCRIPT_TAIL_RE = /\s+\[\d+\]\s+(?:user|assistant|system|developer|tool(?:\s+[a-z0-9_.-]+){0,4})\s*:\s[\s\S]*$/iu; const MARKDOWN_IMAGE_RE = /!\[[^\]\r\n]*\]\((?:<[^>\r\n]+>|[^)\r\n]+)\)/gu; @@ -55,6 +55,12 @@ export function privacySafeUserInputText(value, { limit = 800 } = {}) { return sanitizePrivateReviewText(prepared, { limit }); } +export function isContextOnlyUserInput(value) { + if (!String(value ?? "").trim()) return false; + const prepared = prepareTaskInput(value); + return !prepared || isExcludedUserInput(prepared); +} + export function sanitizePrivateReviewText(value, { limit = 800 } = {}) { if (!value) return null; const maximum = Math.max(24, Number(limit) || 800); diff --git a/test/cli/source-plugin-runtime.test.mjs b/test/cli/source-plugin-runtime.test.mjs new file mode 100644 index 00000000..9fa1407c --- /dev/null +++ b/test/cli/source-plugin-runtime.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { cp, mkdir, mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "vitest"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + +test("a Git source plugin isolates missing DSH dependencies from Codex routes", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-source-plugin-")); + try { + const plugin = path.join(root, "plugin"); + const workspace = path.join(root, "workspace"); + const hostHome = path.join(root, "host-home"); + await mkdir(workspace); + await mkdir(hostHome); + await cp(path.join(repoRoot, "scripts"), path.join(plugin, "scripts"), { recursive: true }); + await cp(path.join(repoRoot, "package.json"), path.join(plugin, "package.json")); + const run = (args) => spawnSync(process.execPath, [path.join(plugin, "scripts", "better-harness.mjs"), ...args], { + cwd: workspace, + encoding: "utf8", + env: { ...process.env, HOME: hostHome, USERPROFILE: hostHome }, + timeout: 30_000, + }); + + const help = run(["--help"]); + assert.equal(help.status, 0, help.stderr); + const codex = run(["agent-customize", "inventory", "--provider", "codex", "--workspace", workspace, + "--codex-home", hostHome, "--codex-app-path", path.join(root, "absent-app")]); + assert.equal(codex.status, 0, codex.stderr); + assert.equal(JSON.parse(codex.stdout).provider, "codex"); + + const dsh = run(["agent-customize", "inventory", "--provider", "dsh", "--workspace", workspace]); + assert.notEqual(dsh.status, 0); + assert.match(dsh.stderr, /DSH configured-assets inventory requires the yaml runtime dependency/u); + assert.match(dsh.stderr, /npm ci/u); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/test/sessions/codex-evidence-window.test.mjs b/test/sessions/codex-evidence-window.test.mjs new file mode 100644 index 00000000..351dea33 --- /dev/null +++ b/test/sessions/codex-evidence-window.test.mjs @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { test } from "vitest"; +import { CodexSessionAnalyzer } from "../../scripts/session-analysis/platforms/codex.mjs"; +import { buildTaskEpisodes } from "../../scripts/session-analysis/episode-contract.mjs"; +import { privacySafeUserInputText } from "../../scripts/session-analysis/privacy-safe-text.mjs"; + +const since = "2026-10-10T08:00:00.000Z"; +const until = "2026-10-10T08:35:00.000Z"; +const pageContext = '{"page_id":null}'; +const browserContext = 'Current URL: https://example.invalid'; + +test("Codex discovery includes cross-day activity and hydrates only the inclusive window", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-codex-window-")); + try { + const workspace = path.join(root, "project"); + const home = path.join(root, "codex-home"); + const sessionDir = path.join(home, "sessions", "2026", "10", "09"); + await mkdir(sessionDir, { recursive: true }); + const writeSession = async (id, cwd, timestamps) => { + const rows = [ + { type: "session_meta", timestamp: "2026-10-09T10:00:00.000Z", payload: { id, cwd } }, + ...timestamps.map((timestamp) => ({ type: "event_msg", timestamp, + payload: { type: "user_message", message: "Continue the accepted task" } })), + ]; + await writeFile(path.join(sessionDir, `${id}.jsonl`), rows.map(JSON.stringify).join("\n")); + }; + await writeSession("cross-day", workspace, ["2026-10-10T07:59:59.999Z", since, until, "2026-10-10T08:35:00.001Z"]); + await writeSession("inactive", workspace, ["2026-10-10T07:59:59.999Z"]); + await writeSession("future", workspace, ["2026-10-10T08:35:00.001Z"]); + await writeSession("other-workspace", path.join(root, "other"), [since]); + await writeSession("other-session", workspace, [since]); + + const analyzer = new CodexSessionAnalyzer(); + const options = { home, workspace, since, until, command: "sessions" }; + const index = await analyzer.analyze(options); + assert.deepEqual(index.sessions.map((session) => session.sessionId).sort(), ["cross-day", "other-session"]); + const targeted = await analyzer.analyze({ ...options, "session-id": "cross-day" }); + assert.equal(targeted.sessions.length, 1); + const scope = await analyzer.resolveScope({ ...options, "session-id": "cross-day" }); + const events = await analyzer.readSession(targeted.sessions[0], scope, { includeUserText: true }); + assert.deepEqual(events.map((event) => event.timestamp), [since, until]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("host UI context is not a request, while accompanying user text is preserved", () => { + assert.equal(privacySafeUserInputText(pageContext), null); + assert.equal(privacySafeUserInputText(browserContext), null); + assert.equal(privacySafeUserInputText(`${browserContext}\n\n## My request:\nShip the accepted change`), "Ship the accepted change"); + assert.equal(privacySafeUserInputText(`${pageContext}\nInvestigate the failure`), "Investigate the failure"); + + const analyzer = new CodexSessionAnalyzer(); + const normalize = (sessionId, offset, message, type = "user", extra = {}) => analyzer.normalizeEvent({ + timestamp: new Date(Date.parse(since) + offset * 1000).toISOString(), + type: "response_item", payload: type === "user" + ? { type: "message", role: "user", content: [{ type: "input_text", text: message }] } + : { type: "function_call", name: "exec_command", call_id: `${sessionId}-${offset}`, arguments: JSON.stringify({ cmd: "npm test" }) }, + }, { kind: "codex-session-jsonl", sessionId, line: offset + 1 }, { includeUserText: true, includeCommandText: true, ...extra }); + + const events = [ + normalize("task-a", 0, pageContext), + normalize("task-a", 1, "Repair alpha behavior"), + normalize("task-a", 2, null, "tool"), + normalize("task-a", 3, browserContext), + normalize("task-a", 4, null, "tool"), + normalize("task-b", 0, pageContext), + normalize("task-b", 1, "Repair beta behavior"), + normalize("task-b", 2, null, "tool"), + ]; + assert.equal(events[0].type, "context.user-input"); + assert.equal(events[3].type, "context.user-input"); + const result = buildTaskEpisodes(events, { platform: "codex", includeEpisodeFacts: true }); + assert.equal(result.episodes.length, 2); + assert.deepEqual(result.episodeFacts.entries.map((entry) => entry.request.summary).sort(), ["Repair alpha behavior", "Repair beta behavior"]); + assert.ok(result.episodeFacts.entries.every((entry) => entry.request.occurrences === 1)); +}); + +test("Codex exec wrappers retain observed activity without inventing nested success", () => { + const analyzer = new CodexSessionAnalyzer(); + const source = { kind: "codex-session-jsonl", sessionId: "wrapper-task", line: 1 }; + const rows = [ + { type: "event_msg", timestamp: since, payload: { type: "user_message", message: "Review the accepted release" } }, + { type: "response_item", timestamp: "2026-10-10T08:00:01.000Z", payload: { + type: "custom_tool_call", name: "exec", call_id: "wrapper-call", + input: 'text(await tools.exec_command({cmd:"git status --short"}));', + } }, + { type: "response_item", timestamp: "2026-10-10T08:00:02.000Z", payload: { + type: "custom_tool_call_output", call_id: "wrapper-call", output: "Script completed\nOutput: working tree clean", + } }, + { type: "response_item", timestamp: "2026-10-10T08:00:03.000Z", payload: { + type: "message", role: "assistant", content: [{ type: "output_text", text: "The observed state is ready for review." }], + } }, + ]; + const events = rows.map((row, index) => analyzer.normalizeEvent(row, { ...source, line: index + 1 }, { + includeUserText: true, includeCommandText: true, + })); + const facts = buildTaskEpisodes(events, { platform: "codex", includeEpisodeFacts: true }).episodeFacts; + assert.equal(facts.entries.length, 1); + const candidate = facts.entries[0]; + assert.deepEqual(candidate.activity, { toolCalls: 1 }); + assert.deepEqual(candidate.workTrace, { steps: ["execute", "handoff"] }); + assert.deepEqual(candidate.changes, { edits: 0, files: 0 }); + assert.deepEqual(candidate.checks, []); + assert.equal(candidate.closure, "no-change-observed"); + assert.equal(candidate.result.structuredCompletionObserved, undefined); +}); + +test("image-only user requests preserve task boundaries after privacy sanitization", () => { + const analyzer = new CodexSessionAnalyzer(); + const source = { kind: "codex-session-jsonl", sessionId: "image-task" }; + for (const image of [ + "![New design to implement](https://example.invalid/new-design.png)", + '', + ]) { + assert.equal(privacySafeUserInputText(image), null); + const user = (message) => ({ type: "message", role: "user", content: [{ type: "input_text", text: message }] }); + const edit = (id) => ({ type: "custom_tool_call", name: "apply_patch", call_id: id, + input: "*** Begin Patch\n*** Update File: src/app.mjs\n@@\n-old\n+new\n*** End Patch" }); + const events = [user("Repair alpha behavior"), edit("first-edit"), user(image), edit("second-edit")] + .map((payload, index) => analyzer.normalizeEvent({ type: "response_item", payload, + timestamp: new Date(Date.parse(since) + index * 1000).toISOString(), + }, { ...source, line: index + 1 }, { includeUserText: true, includeCommandText: true })); + + assert.equal(events[2].type, "user"); + const result = buildTaskEpisodes(events, { platform: "codex", includeEpisodeFacts: true }); + assert.equal(result.episodes.length, 2); + assert.equal(result.episodeFacts.entries.find((entry) => entry.request.summary === "Repair alpha behavior").changes.edits, 1); + } +}); From 0dcd2380cc79dc680b62e19410ee6a573c4879ef Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Mon, 12 Oct 2026 11:46:22 +0800 Subject: [PATCH 2/2] fix(codex): preserve native image request boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 PR #196 审查发现的多模态请求归属问题:在原始消息层识别 input_image,避免页面或浏览器上下文使图片请求降级为上下文, 并为纯图片请求保留 userPrompt 标记。图片内容和地址仍不进入摘要。 补充四个场景及三种文本输出模式的回归,修复前均失败、修复后通过。 按 docs/specs/2026-10-12-codex-source-plugin-session-evidence.md 验证, 聚焦测试 33 通过,全量根测试 1782 通过、7 项条件跳过,文档链接 和 git diff --check 通过。 Co-authored-by: QoderAI --- ...12-codex-source-plugin-session-evidence.md | 41 +++++++++++++++++++ scripts/session-analysis/platforms/codex.mjs | 10 ++++- test/sessions/codex-evidence-window.test.mjs | 39 ++++++++++++++++++ 3 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 docs/specs/2026-10-12-codex-source-plugin-session-evidence.md diff --git a/docs/specs/2026-10-12-codex-source-plugin-session-evidence.md b/docs/specs/2026-10-12-codex-source-plugin-session-evidence.md new file mode 100644 index 00000000..62a1c697 --- /dev/null +++ b/docs/specs/2026-10-12-codex-source-plugin-session-evidence.md @@ -0,0 +1,41 @@ +# 恢复 Codex 插件启动与会话证据边界 + +## Traceability + +- Spec ID: codex-source-plugin-session-evidence +- PR: https://github.com/QoderAI/better-harness/pull/196 +- Status: Implemented + +## Intent + +隔离可选 DSH 依赖,恢复时间窗口内继续活动的 Codex 会话,并区分宿主上下文与真实用户请求。PR 审查发现原生 `input_image` 附件被文本提取忽略,与页面上下文共存时会丢失任务边界,导致后续修改归入前一任务。 + +## Acceptance Scenarios + +- AC-1: 源码插件未安装 YAML 时,Codex 路由仍可启动;仅选择 DSH 时返回可操作的依赖错误,不修改依赖版本。 +- AC-2: 开始时间早于 `since`、但在闭区间内有活动的会话可被发现;读取结果保持时间、工作区和会话身份过滤。 +- AC-3: 单独的页面或浏览器上下文不会成为任务请求;附带真实文字请求时保留该文字。 +- AC-4: Markdown、XML 和原生 `input_image` 请求均保留任务边界,包括原生图片与页面/浏览器上下文共存的情况。文字任务及图片任务各发生一次修改时,形成两个 episode,前一任务不吸收后一修改;是否启用文本输出不影响分类,图片地址和内容不会进入归一化事件或摘要。 +- AC-5: `exec` wrapper 只贡献可观察活动,不推断嵌套命令成功、验证通过或发布批准。 + +## Non-goals + +不识别图片内容,不新增附件格式,不修改原生桌面适配器、发布策略、依赖版本或无关 CI 门禁,不读取真实私人会话。 + +## Plan and Tasks + +1. 核验现有 provider 惰性导入、窗口发现、上下文过滤与 wrapper 活动测试(AC-1/2/3/5)。 +2. 扩展匿名多模态消息回归,先确认原实现失败(AC-4)。 +3. 在 Codex 原始消息层识别原生图片,保留 `user` 类型及 `userPrompt` 标记;继续使用现有文本输出和隐私清理边界(AC-4)。 +4. 执行相关及全量根测试、文档链接检查,核对 PR 的跨平台 CI,再提交修复并 squash 合并。 + +## Test and Review Evidence + +- 独立审查报告:`input_image` 与宿主上下文共存时,两个任务被折叠成一个;其余改动未发现已确认的高信号问题。 +- AC-4:新增 4 个回归场景在修复前全部失败,修复后通过;每个场景验证不输出文本、输出用户文本、输出内容三种模式,两个 episode 各保留一次修改,图片无可用摘要时不生成虚构事实。 +- AC-1/2/3/4/5:`npm test -- test/sessions/codex-evidence-window.test.mjs test/cli/source-plugin-runtime.test.mjs test/agents/agent-customize-dsh.test.mjs test/skills-docs/doc-link-graph.test.mjs`,4 文件、33 测试通过。 +- macOS / Node 24.18.0:隔离 worktree 内执行全量 `npm test`,119 文件通过、1 文件跳过;1782 测试通过、7 测试按既有前置条件跳过,0 失败。 +- 初次使用 `vitest --root` 从父目录启动的结果受 cwd 与依赖目录隔离影响,不作为产品回归证据;worktree 内独立安装依赖并通过 `npm --prefix` 使用正确 cwd 后复验通过。 +- 文档路由图重新生成后无差异;`git diff --check` 通过。修复提交后的跨平台 CI 结果以 PR 检查记录为准。 +- 风险:不能用隐私清理后的空文本判定请求不存在;图片识别仅用于分类,不复制或解码附件。 +- 本地 macOS 结果不能替代 Windows/Linux 的 GitHub Actions 结果。 diff --git a/scripts/session-analysis/platforms/codex.mjs b/scripts/session-analysis/platforms/codex.mjs index 21b24137..d76b51d6 100644 --- a/scripts/session-analysis/platforms/codex.mjs +++ b/scripts/session-analysis/platforms/codex.mjs @@ -364,6 +364,11 @@ function inferLoadedSkillNames(raw) { return names; } +function hasInputImage(raw) { + const message = raw?.message ?? raw?.content ?? raw?.payload?.message ?? raw?.payload?.content ?? null; + return Array.isArray(message) && message.some((item) => item?.type === "input_image"); +} + function messageText(raw) { const message = raw?.message ?? raw?.content ?? raw?.payload?.message ?? raw?.payload?.content ?? null; if (typeof message === "string") { @@ -900,7 +905,8 @@ export class CodexSessionAnalyzer extends SessionAnalyzer { normalizeEvent(raw, sourceRef, options = {}) { const text = messageText(raw); const rawType = inferType(raw); - const type = rawType === "user" && isContextOnlyUserInput(text) + const imageInput = rawType === "user" && hasInputImage(raw); + const type = rawType === "user" && !imageInput && isContextOnlyUserInput(text) ? "context.user-input" : rawType; const retainsDialogueText = ["user", "assistant", "last-prompt", "UserPromptSubmit"].includes(type); @@ -969,7 +975,7 @@ export class CodexSessionAnalyzer extends SessionAnalyzer { const cwd = inferCwd(raw); if (cwd) event.cwd = cwd; if (type === "user" || type === "last-prompt" || type === "UserPromptSubmit") { - event.userPrompt = type === "UserPromptSubmit" || userText(raw).trim().length > 0; + event.userPrompt = type === "UserPromptSubmit" || imageInput || userText(raw).trim().length > 0; } if (isUserVisibleAssistantMessage(raw)) { event.userVisibleAssistantMessage = true; diff --git a/test/sessions/codex-evidence-window.test.mjs b/test/sessions/codex-evidence-window.test.mjs index 351dea33..d4b8cecc 100644 --- a/test/sessions/codex-evidence-window.test.mjs +++ b/test/sessions/codex-evidence-window.test.mjs @@ -109,6 +109,45 @@ test("Codex exec wrappers retain observed activity without inventing nested succ assert.equal(candidate.result.structuredCompletionObserved, undefined); }); +test.each([ + ["image only", ""], + ["page context", pageContext], + ["browser context", browserContext], + ["combined context", `${pageContext}\n${browserContext}`], +])("native image requests preserve task boundaries and omit image data: %s", (_name, context) => { + const analyzer = new CodexSessionAnalyzer(); + const imageUrl = "https://example.invalid/private-design.png"; + const user = (content) => ({ type: "message", role: "user", content }); + const edit = (id) => ({ type: "custom_tool_call", name: "apply_patch", call_id: id, + input: "*** Begin Patch\n*** Update File: src/app.mjs\n@@\n-old\n+new\n*** End Patch" }); + const rows = [ + user([{ type: "input_text", text: "Repair alpha behavior" }]), + edit("first-edit"), + user([ + ...(context ? [{ type: "input_text", text: context }] : []), + { type: "input_image", image_url: imageUrl }, + ]), + edit("second-edit"), + ]; + for (const textOptions of [{}, { includeUserText: true }, { includeContent: true }]) { + const events = rows.map((payload, index) => analyzer.normalizeEvent({ type: "response_item", payload, + timestamp: new Date(Date.parse(since) + index * 1000).toISOString(), + }, { kind: "codex-session-jsonl", sessionId: "native-image-task", line: index + 1 }, { + ...textOptions, includeCommandText: true, + })); + assert.equal(events[2].type, "user"); + assert.equal(events[2].userPrompt, true); + assert.equal(privacySafeUserInputText(events[2].userText ?? events[2].content), null); + assert.equal(JSON.stringify(events).includes(imageUrl), false); + const result = buildTaskEpisodes(events, { platform: "codex", includeEpisodeFacts: true }); + assert.equal(result.episodes.length, 2); + assert.deepEqual(result.episodes.map((episode) => episode.changeSets.map((change) => change.eventCount)), [[1], [1]]); + assert.deepEqual(result.episodeFacts.entries.map((entry) => entry.changes.edits), + textOptions.includeUserText || textOptions.includeContent ? [1] : []); + assert.equal(JSON.stringify(result).includes(imageUrl), false); + } +}); + test("image-only user requests preserve task boundaries after privacy sanitization", () => { const analyzer = new CodexSessionAnalyzer(); const source = { kind: "codex-session-jsonl", sessionId: "image-task" };