fix: 修复 Web observe turn 可见性
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user