fix: 修复 Web observe turn 可见性

This commit is contained in:
Codex
2026-07-10 07:01:23 +02:00
parent f128d73663
commit 5e8d3201ec
10 changed files with 350 additions and 19 deletions
@@ -9,6 +9,10 @@ export function compactObserveCollectForRaw(collect: Record<string, unknown> | n
return {
round: row.round ?? null,
commandId: row.commandId ?? null,
controlExecutionStatus: row.controlExecutionStatus ?? null,
turnSubmissionStatus: row.turnSubmissionStatus ?? null,
turnStatus: row.turnStatus ?? row.status ?? null,
turnTerminalObserved: row.turnTerminalObserved === true,
userHash: row.userHash ?? null,
userBytes: row.userBytes ?? null,
traceId: row.traceId ?? null,
@@ -191,8 +195,12 @@ export function compactObserveCollectForRaw(collect: Record<string, unknown> | n
ok: collect.ok !== false,
command: collect.command,
view: collect.view,
status: collect.status,
stateDir: collect.stateDir,
turnCount: collect.turnCount,
availableTurnCount: collect.availableTurnCount,
filter: observeRecord(collect.filter),
error: observeRecord(collect.error),
artifactFileCount: collect.artifactFileCount ?? collect.fileCount,
skippedFileCount: collect.skippedFileCount,
skippedFiles: Array.isArray(collect.skippedFiles) ? collect.skippedFiles.slice(0, 8) : undefined,
@@ -209,6 +217,7 @@ export function compactObserveCollectForRaw(collect: Record<string, unknown> | n
traceId: collect.traceId,
finalResponse: collect.finalResponse,
statusGap: observeRecord(collect.statusGap),
evidenceReconciliation: observeRecord(collect.evidenceReconciliation),
dom: observeRecord(collect.dom),
networkEvidence: collectView === "workbench-triad" ? slimNetworkEvidence(collect.networkEvidence) : observeRecord(collect.networkEvidence),
freeze: collectView === "workbench-triad" ? slimFreeze(collect.freeze) : observeRecord(collect.freeze),
@@ -0,0 +1,101 @@
import assert from "node:assert/strict";
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { test } from "bun:test";
import { nodeWebObserveCollectViewNodeScript, type NodeWebProbeObserveCollectView } from "../hwlab-node-web-observe-collect";
function shellQuote(value: string): string {
return `\'${value.replace(/\'/g, `\'\\\'\'`)}\'`;
}
async function writeJsonl(path: string, rows: Array<Record<string, unknown>>): Promise<void> {
await writeFile(path, rows.map((row) => JSON.stringify(row)).join("\n") + (rows.length > 0 ? "\n" : ""));
}
async function fixtureState(): Promise<string> {
const stateDir = await mkdtemp(join(tmpdir(), "unidesk-turn-visibility-"));
await writeFile(join(stateDir, "manifest.json"), JSON.stringify({ jobId: "webobs-turn-visibility" }) + "\n");
await writeJsonl(join(stateDir, "control.jsonl"), [
{ type: "sendPrompt", phase: "started", commandId: "cmd-first", sessionId: "ses-fixture", ts: "2026-07-10T01:00:00.000Z", input: { textPreview: "FIRST_UNRELATED", textHash: "sha256:first", textBytes: 15 } },
{ type: "sendPrompt", phase: "completed", commandId: "cmd-first", sessionId: "ses-fixture", ts: "2026-07-10T01:00:01.000Z", detail: { chatSubmit: { status: 202, traceId: "trc_first", otelTraceId: "otel-first" } } },
{ type: "sendPrompt", phase: "started", commandId: "cmd-second", sessionId: "ses-fixture", ts: "2026-07-10T01:01:00.000Z", input: { textPreview: "SECOND_TARGET", textHash: "sha256:second", textBytes: 13 } },
{ type: "sendPrompt", phase: "completed", commandId: "cmd-second", sessionId: "ses-fixture", ts: "2026-07-10T01:01:01.000Z", detail: { chatSubmit: { status: 202, traceId: "trc_second", otelTraceId: "otel-second" } } },
]);
await writeJsonl(join(stateDir, "samples.jsonl"), [
{ seq: 1, ts: "2026-07-10T01:00:02.000Z", routeSessionId: "ses-fixture", turns: [{ traceId: "trc_first", status: "completed", text: "completed" }], messages: [{ traceId: "trc_first", role: "assistant", status: "completed", text: "FIRST_FINAL" }], traceRows: [] },
{ seq: 2, ts: "2026-07-10T01:01:02.000Z", routeSessionId: "ses-fixture", turns: [{ traceId: "trc_second", status: "completed", text: "completed" }], messages: [{ traceId: "trc_second", role: "assistant", status: "completed", text: "SECOND_FINAL" }], traceRows: [] },
]);
await writeJsonl(join(stateDir, "network.jsonl"), []);
await writeJsonl(join(stateDir, "browser-process.jsonl"), []);
return stateDir;
}
function collect(stateDir: string, view: NodeWebProbeObserveCollectView, input: { commandId?: string; traceId?: string } = {}): Record<string, unknown> {
const script = nodeWebObserveCollectViewNodeScript({
maxFiles: 100,
view,
traceId: input.traceId ?? null,
sampleSeq: null,
timestamp: null,
turn: null,
commandId: input.commandId ?? null,
windowMs: null,
});
const result = spawnSync("bash", ["-lc", `state_dir=${shellQuote(stateDir)}\n${script}`], {
cwd: join(import.meta.dir, "../../.."),
encoding: "utf8",
});
assert.equal(result.status, 0, result.stderr || result.stdout);
return JSON.parse(result.stdout) as Record<string, unknown>;
}
test("turn-summary command-id selects exactly one turn and separates control from async state", async () => {
const output = collect(await fixtureState(), "turn-summary", { commandId: "cmd-second" });
assert.equal(output.ok, true);
assert.equal(output.status, "matched");
assert.equal(output.turnCount, 1);
assert.equal(output.availableTurnCount, 2);
const filter = output.filter as Record<string, unknown>;
assert.equal(filter.mode, "exact-command-id");
assert.equal(filter.matchedCount, 1);
const rows = output.rows as Array<Record<string, unknown>>;
assert.equal(rows.length, 1);
assert.equal(rows[0].round, 2);
assert.equal(rows[0].commandId, "cmd-second");
assert.equal(rows[0].controlExecutionStatus, "completed");
assert.equal(rows[0].turnSubmissionStatus, "accepted");
assert.equal(rows[0].turnStatus, "completed");
assert.equal(rows[0].turnTerminalObserved, true);
assert.doesNotMatch(String(output.renderedText), /FIRST_UNRELATED|FIRST_FINAL/u);
assert.match(String(output.renderedText), /Control.*Submit.*Async turn/u);
});
test("turn-summary missing command-id is structured not-found without all-turn fallback", async () => {
const output = collect(await fixtureState(), "turn-summary", { commandId: "cmd-missing" });
assert.equal(output.ok, false);
assert.equal(output.status, "not-found");
assert.equal(output.turnCount, 0);
assert.deepEqual(output.rows, []);
const error = output.error as Record<string, unknown>;
assert.equal(error.code, "turn-summary-command-not-found");
assert.doesNotMatch(String(output.renderedText), /FIRST_UNRELATED|SECOND_TARGET|FIRST_FINAL|SECOND_FINAL/u);
assert.match(String(output.renderedText), /unrelated turns were not returned/u);
});
test("workbench-triad explains missing local API artifact and keeps OTel as independent evidence", async () => {
const output = collect(await fixtureState(), "workbench-triad", { traceId: "trc_second" });
const reconciliation = output.evidenceReconciliation as Record<string, unknown>;
assert.equal(reconciliation.status, "artifact-evidence-missing");
assert.equal(reconciliation.businessApiAbsenceProven, false);
const localApi = reconciliation.localApiArtifact as Record<string, unknown>;
assert.equal(localApi.status, "missing");
const otel = reconciliation.otel as Record<string, unknown>;
assert.equal(otel.status, "trace-reference-present");
assert.equal(otel.otelTraceId, "otel-second");
assert.equal(otel.coverageStatus, "not-evaluated-by-offline-artifact-view");
assert.match(String(output.renderedText), /artifact evidence missing/u);
assert.match(String(output.renderedText), /does not mean the business API did not occur/u);
});
@@ -1,8 +1,28 @@
import assert from "node:assert/strict";
import { test } from "bun:test";
import { withWebObserveCommandRendered } from "../hwlab-node-web-observe-render";
import { renderWebProbeScriptResult } from "./web-observe-render";
test("observe command renderer separates control completion from async turn terminal state", () => {
const rendered = withWebObserveCommandRendered({
ok: true,
status: "control-completed",
command: "web-probe observe command",
id: "webobs-fixture",
commandId: "cmd-fixture",
observerCommand: { type: "sendPrompt" },
observer: { ok: true, completedAt: "2026-07-10T01:00:01.000Z", result: { mark: "submitted" } },
control: { executionStatus: "completed", businessTurnTerminalImplied: false },
asyncTurn: { applicable: true, submissionStatus: "accepted", terminalStatus: null, terminalObserved: false, traceId: "trc_fixture" },
});
const text = String(rendered.renderedText);
assert.match(text, /CONTROL.*TURN_SUBMIT.*TURN_TERMINAL/u);
assert.match(text, /completed.*accepted.*not-observed.*trc_fixture/u);
assert.match(text, /does not imply the asynchronous business turn reached a terminal state/u);
assert.match(text, /--view turn-summary --command-id cmd-fixture/u);
});
test("web-probe script render warns to promote repeated scripts into commands", () => {
const rendered = renderWebProbeScriptResult({
ok: true,
@@ -4,7 +4,32 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "bun:test";
import { resolveWebObserveActionJson } from "./web-probe-observe-actions";
import { buildWebObserveCommandVisibility, resolveWebObserveActionJson } from "./web-probe-observe-actions";
test("web observe command visibility does not equate control completion with async turn completion", () => {
const visibility = buildWebObserveCommandVisibility({
commandType: "sendPrompt",
exitCode: 0,
commandResult: {
ok: true,
completedAt: "2026-07-10T01:00:01.000Z",
result: {
chatSubmit: {
status: 202,
traceId: "trc_fixture",
otelTraceId: "otel_fixture",
},
},
},
});
assert.equal(visibility.status, "control-completed");
assert.equal(visibility.control.executionStatus, "completed");
assert.equal(visibility.control.businessTurnTerminalImplied, false);
assert.equal(visibility.asyncTurn.submissionStatus, "accepted");
assert.equal(visibility.asyncTurn.terminalStatus, null);
assert.equal(visibility.asyncTurn.terminalObserved, false);
assert.equal(visibility.asyncTurn.terminalStatusSource, "not-evaluated-by-control-command");
});
test("web observe action JSON recovery accepts concrete start contract", () => {
const resolved = resolveWebObserveActionJson({
@@ -603,6 +603,68 @@ export function webObserveShort(value: string, maxLength: number): string {
return `${value.slice(0, maxLength - 1)}~`;
}
export function buildWebObserveCommandVisibility(input: {
commandType: string;
commandResult: Record<string, unknown> | null;
exitCode: number | null;
}): {
status: string;
control: Record<string, unknown>;
asyncTurn: Record<string, unknown>;
} {
const commandResult = input.commandResult;
const executionStatus = input.exitCode !== 0 || commandResult === null
? "blocked"
: commandResult.ok === false
? "failed"
: commandResult.completedAt !== undefined && commandResult.completedAt !== null
? "completed"
: commandResult.queued === true || commandResult.waitTimedOut === true
? "queued"
: "accepted";
const actionResult = record(commandResult?.result);
const chatSubmit = record(actionResult.chatSubmit);
const traceId = typeof chatSubmit.traceId === "string" ? chatSubmit.traceId : null;
const otelTraceId = typeof chatSubmit.otelTraceId === "string" ? chatSubmit.otelTraceId : null;
const httpStatus = typeof chatSubmit.status === "number" ? chatSubmit.status : null;
const turnApplicable = input.commandType === "sendPrompt" || input.commandType === "steer";
const submissionAccepted = chatSubmit.sideEffectObserved === true
|| httpStatus !== null && httpStatus >= 200 && httpStatus < 300
|| traceId !== null;
const submissionStatus = !turnApplicable
? "not-applicable"
: executionStatus === "blocked"
? "not-submitted"
: executionStatus === "failed"
? "failed"
: submissionAccepted
? "accepted"
: executionStatus === "queued"
? "pending"
: "not-observed";
return {
status: "control-" + executionStatus,
control: {
executionStatus,
completedAt: commandResult?.completedAt ?? null,
completionMeaning: executionStatus === "completed" ? "observer-browser-control-action-finished" : null,
businessTurnTerminalImplied: false,
valuesRedacted: true,
},
asyncTurn: {
applicable: turnApplicable,
submissionStatus,
submissionHttpStatus: httpStatus,
terminalStatus: null,
terminalObserved: false,
terminalStatusSource: turnApplicable ? "not-evaluated-by-control-command" : "not-applicable",
traceId,
otelTraceId,
valuesRedacted: true,
},
};
}
export function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOptions, spec: HwlabRuntimeLaneSpec, stopCommand: boolean): Record<string, unknown> | RenderedCliResult {
const type = options.commandType ?? (stopCommand ? "stop" : null);
if (type === null) throw new Error("web-probe observe command requires --type");
@@ -682,9 +744,14 @@ export function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOption
: "graceful-stop-queued";
return runNodeWebProbeObserveForceStop(options, spec, payload, commandId, reason, preStopStatus?.result ?? null, preStopStatus?.status ?? null, result);
}
const visibility = buildWebObserveCommandVisibility({
commandType: type,
commandResult,
exitCode: result.exitCode,
});
const payloadResult = {
ok: result.exitCode === 0 && commandResult !== null && commandResult.ok !== false,
status: result.exitCode === 0 && commandResult !== null ? (waitMs > 0 ? "completed-or-queued" : "queued") : "blocked",
status: visibility.status,
command: webObserveCommandLabel(stopCommand ? "stop" : "command", options),
id: webObserveIdFromOptions(options),
node: options.node,
@@ -693,6 +760,8 @@ export function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOption
commandId,
observerCommand: commandSummaryForOutput(payload),
observer: commandResult,
control: visibility.control,
asyncTurn: visibility.asyncTurn,
wrapper: buildWebObserveWrapperForObserveOptions(stopCommand ? "stop" : "command", options, spec.workspace, { commandType: type }),
stdoutJson: commandResolution.diagnostics,
result: compactCommandResult(result),
@@ -49,6 +49,37 @@ describe("web-probe observe collect child JSON recovery", () => {
});
});
test("propagates structured turn-summary not-found status without treating it as collected", () => {
const options = collectOptions();
options.collectView = "turn-summary";
options.collectCommandId = "cmd-missing";
const payload = buildNodeWebProbeObserveCollectPayload(options, { workspace: "/workspace" }, {
command: ["trans", "JD01:/workspace", "sh"],
cwd: "/repo",
exitCode: 0,
stdout: JSON.stringify({
ok: false,
command: "web-probe-observe collect",
view: "turn-summary",
status: "not-found",
stateDir: "/remote/state",
filter: { mode: "exact-command-id", requestedCommandId: "cmd-missing", matchedCount: 0 },
error: { code: "turn-summary-command-not-found" },
rows: [],
renderedText: "NOT_FOUND turn-summary-command-not-found; unrelated turns were not returned",
valuesRedacted: true,
}),
stderr: "",
signal: null,
timedOut: false,
} satisfies CommandResult);
expect(payload.ok).toBe(false);
expect(payload.status).toBe("not-found");
const collect = payload.collect as Record<string, unknown>;
expect(collect.status).toBe("not-found");
expect(collect.rows).toEqual([]);
});
function collectOptions(): NodeWebProbeObserveOptions {
return {
action: "observe",
@@ -52,9 +52,14 @@ export function buildNodeWebProbeObserveCollectPayload(
const degradedReason = collect === null
? collectDegradedReason(stdoutResolution.diagnostics)
: null;
const collectStatus = collect === null ? null : stringOrNull(collect.status);
return {
ok: result.exitCode === 0 && collect !== null && collect.ok !== false,
status: result.exitCode === 0 && collect !== null ? "collected" : "blocked",
status: result.exitCode === 0 && collect !== null
? collect.ok === false
? collectStatus ?? "blocked"
: collectStatus ?? "collected"
: "blocked",
command: webObserveCommandLabel("collect", options),
id: webObserveIdFromOptions(options),
node: options.node,