Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions docs/specs/2026-10-12-codex-source-plugin-session-evidence.md
Original file line number Diff line number Diff line change
@@ -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 结果。
14 changes: 13 additions & 1 deletion scripts/agent-customize/providers/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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],
Expand Down
4 changes: 2 additions & 2 deletions scripts/session-analysis/episode-facts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}
}
Expand Down
37 changes: 29 additions & 8 deletions scripts/session-analysis/platforms/codex.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -363,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") {
Expand Down Expand Up @@ -681,15 +687,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;
}
Expand Down Expand Up @@ -862,17 +869,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,
Expand All @@ -886,8 +903,12 @@ export class CodexSessionAnalyzer extends SessionAnalyzer {
}

normalizeEvent(raw, sourceRef, options = {}) {
const type = inferType(raw);
const text = messageText(raw);
const rawType = inferType(raw);
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);
const event = {
sessionId: inferSessionId(raw, sourceRef.sessionId),
Expand Down Expand Up @@ -954,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;
Expand Down
8 changes: 7 additions & 1 deletion scripts/session-analysis/privacy-safe-text.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const INJECTED_CONTEXT_PREFIX_RE = /^(?:#\s*AGENTS\.md instructions\b|<environment_context>|<skill>|#\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 = /<turn_aborted\b[^>]*>[\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;
Expand Down Expand Up @@ -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);
Expand Down
42 changes: 42 additions & 0 deletions test/cli/source-plugin-runtime.test.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
});
Loading
Loading