mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-04 02:00:10 +00:00
fix(cli): report recorded trajectory terminal outcomes (#136407)
Reuse the canonical terminal classifier for sessions tail and honor the producer-recorded delivery error. Remove duplicate target state and single-use rendering wrappers while preserving follow selection and cursor. Validation: 109 selected tests; nine real compiled CLI cases with isolated SQLite state; explicit-base changed checks; independent P2 review.
This commit is contained in:
parent
f1ae53b736
commit
1623683f47
3 changed files with 74 additions and 61 deletions
|
|
@ -194,7 +194,10 @@ so CJK characters, combining accents, and joined emoji keep progress lines align
|
|||
The progress view is intentionally conservative: prompt text, tool arguments,
|
||||
and tool result bodies are not printed. Tool calls show the tool name with
|
||||
`{...redacted...}`; tool results show status such as `ok`, `error`, or `done`;
|
||||
model completion lines show provider/model and terminal status.
|
||||
model completion lines show provider/model and terminal status. Provider failures
|
||||
and turns without delivery show `error`; cancellation shows `aborted`, timeouts
|
||||
show `timeout`, and successful completions (including delivered partial replies)
|
||||
show `done`.
|
||||
|
||||
## Export a trajectory bundle
|
||||
|
||||
|
|
|
|||
|
|
@ -149,6 +149,49 @@ describe("sessionsTailCommand", () => {
|
|||
expect(output).not.toContain("SECRET");
|
||||
});
|
||||
|
||||
it.each<[string, TrajectoryEvent["data"], string]>([
|
||||
["provider failure", { stopReason: "error", aborted: false, timedOut: false }, "error"],
|
||||
[
|
||||
"tool turn without delivery",
|
||||
{ stopReason: "toolUse", terminalError: "non_deliverable_terminal_turn" },
|
||||
"error",
|
||||
],
|
||||
[
|
||||
"empty terminal reply",
|
||||
{ stopReason: "stop", terminalError: "non_deliverable_terminal_turn" },
|
||||
"error",
|
||||
],
|
||||
["assistant interruption", { stopReason: "aborted", aborted: false }, "aborted"],
|
||||
["prompt failure", { promptError: "sensitive failure detail" }, "error"],
|
||||
[
|
||||
"timeout with abort and failure",
|
||||
{ timedOut: true, aborted: true, promptError: "sensitive failure detail" },
|
||||
"timeout",
|
||||
],
|
||||
["abort with failure", { aborted: true, promptError: "sensitive failure detail" }, "aborted"],
|
||||
["normal stop", { stopReason: "stop" }, "done"],
|
||||
["normal end turn", { stopReason: "end_turn" }, "done"],
|
||||
["delivered partial reply", { stopReason: "length" }, "done"],
|
||||
["unspecified completion", undefined, "done"],
|
||||
])("renders the recorded terminal outcome for %s", async (_name, data, expected) => {
|
||||
const runtime = makeRuntime();
|
||||
await writeSessionEntry();
|
||||
await appendEvents([
|
||||
makeEvent({
|
||||
type: "model.completed",
|
||||
ts: "2026-05-18T12:04:29.000Z",
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.2",
|
||||
data,
|
||||
}),
|
||||
]);
|
||||
|
||||
await sessionsTailCommand({ agent: "main", store: storePath, sessionKey }, runtime);
|
||||
|
||||
expect(runtimeOutput(runtime)).toContain(`openai/gpt-5.2 ${expected}`);
|
||||
expect(runtimeOutput(runtime)).not.toContain("sensitive failure detail");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["ASCII", "incident", "incident"],
|
||||
["CJK", "中文", "中文"],
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
import { parseStrictNonNegativeInteger } from "@openclaw/normalization-core/number-coercion";
|
||||
/**
|
||||
* Session trajectory tail command.
|
||||
*
|
||||
* It selects active or requested sessions, renders recent trajectory events,
|
||||
* and can follow newly appended SQLite trajectory rows.
|
||||
*/
|
||||
import { normalizeOptionalString as toOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js";
|
||||
import { readAcpSessionMeta } from "../acp/runtime/session-meta.js";
|
||||
import {
|
||||
buildAgentRunTerminalOutcomeFromLifecycleEvent,
|
||||
classifyAgentRunTerminalOutcome,
|
||||
} from "../agents/agent-run-terminal-outcome.js";
|
||||
import { getRuntimeConfig } from "../config/config.js";
|
||||
import { listSessionEntriesReadOnly } from "../config/sessions/session-accessor.js";
|
||||
import type { SessionEntry } from "../config/sessions/types.js";
|
||||
|
|
@ -34,13 +32,7 @@ type TailSelection = {
|
|||
key: string;
|
||||
entry: SessionEntry;
|
||||
storePath: string;
|
||||
source: TailTrajectorySource;
|
||||
};
|
||||
|
||||
type TailTrajectorySource = {
|
||||
agentId: string;
|
||||
sessionId: string;
|
||||
storePath: string;
|
||||
};
|
||||
|
||||
type SqliteFollowState = {
|
||||
|
|
@ -73,15 +65,6 @@ function formatTimestamp(ts: string): string {
|
|||
return date.toISOString().slice(11, 19);
|
||||
}
|
||||
|
||||
function modelLabel(event: TrajectoryEvent): string | undefined {
|
||||
const provider = event.provider?.trim();
|
||||
const model = event.modelId?.trim();
|
||||
if (provider && model) {
|
||||
return `${provider}/${model}`;
|
||||
}
|
||||
return model || provider || undefined;
|
||||
}
|
||||
|
||||
function toolName(data: Record<string, unknown> | undefined): string {
|
||||
return toOptionalString(data?.name) ?? toOptionalString(data?.toolName) ?? "tool";
|
||||
}
|
||||
|
|
@ -97,16 +80,20 @@ function resultStatus(data: Record<string, unknown> | undefined): string {
|
|||
}
|
||||
|
||||
function modelCompletionStatus(data: Record<string, unknown> | undefined): string {
|
||||
if (data?.timedOut === true) {
|
||||
return "timeout";
|
||||
}
|
||||
if (data?.aborted === true) {
|
||||
return "aborted";
|
||||
}
|
||||
if (toOptionalString(data?.promptError)) {
|
||||
return "error";
|
||||
}
|
||||
return "done";
|
||||
const outcome = buildAgentRunTerminalOutcomeFromLifecycleEvent({
|
||||
phase: "end",
|
||||
data: {
|
||||
...data,
|
||||
// Attempt timeouts can also record an abort; retain the owner's timeout attribution.
|
||||
stopReason: data?.timedOut === true ? "timeout" : data?.stopReason,
|
||||
},
|
||||
});
|
||||
return {
|
||||
success: data?.promptError || data?.promptErrorSource || data?.terminalError ? "error" : "done",
|
||||
failure: "error",
|
||||
timeout: "timeout",
|
||||
cancellation: "aborted",
|
||||
}[classifyAgentRunTerminalOutcome(outcome)];
|
||||
}
|
||||
|
||||
function safePreview(event: TrajectoryEvent): string {
|
||||
|
|
@ -133,7 +120,7 @@ function safePreview(event: TrajectoryEvent): string {
|
|||
case "tool.result":
|
||||
return `${toolName(data)} ${resultStatus(data)}`;
|
||||
case "model.completed": {
|
||||
const model = modelLabel(event);
|
||||
const model = [event.provider?.trim(), event.modelId?.trim()].filter(Boolean).join("/");
|
||||
const status = modelCompletionStatus(data);
|
||||
return model ? `${model} ${status}` : status;
|
||||
}
|
||||
|
|
@ -154,14 +141,11 @@ function formatProgressLine(event: TrajectoryEvent): string {
|
|||
return [formatTimestamp(event.ts), typeLabel, sessionLabel, preview].join(" ").trimEnd();
|
||||
}
|
||||
|
||||
function readSqliteTrajectorySnapshot(
|
||||
source: TailTrajectorySource,
|
||||
tailEvents: number,
|
||||
): TrajectorySnapshot {
|
||||
function readTailSnapshot(selection: TailSelection, tailEvents: number): TrajectorySnapshot {
|
||||
const rows = loadSqliteTrajectoryRuntimeEventRowsSync({
|
||||
agentId: source.agentId,
|
||||
sessionId: source.sessionId,
|
||||
storePath: source.storePath,
|
||||
agentId: selection.agentId,
|
||||
sessionId: selection.sessionId,
|
||||
storePath: selection.storePath,
|
||||
tailEvents,
|
||||
});
|
||||
return {
|
||||
|
|
@ -170,10 +154,6 @@ function readSqliteTrajectorySnapshot(
|
|||
};
|
||||
}
|
||||
|
||||
function readTailSnapshot(selection: TailSelection, tailEvents: number): TrajectorySnapshot {
|
||||
return readSqliteTrajectorySnapshot(selection.source, tailEvents);
|
||||
}
|
||||
|
||||
function renderEvents(events: TrajectoryEvent[], runtime: RuntimeEnv): void {
|
||||
for (const event of events) {
|
||||
runtime.log(formatProgressLine(event));
|
||||
|
|
@ -203,20 +183,7 @@ function buildTailSelection(params: {
|
|||
storePath: string;
|
||||
}): TailSelection | null {
|
||||
const sessionId = params.entry.sessionId?.trim();
|
||||
if (!sessionId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
agentId: params.agentId,
|
||||
entry: params.entry,
|
||||
key: params.key,
|
||||
source: {
|
||||
agentId: params.agentId,
|
||||
sessionId,
|
||||
storePath: params.storePath,
|
||||
},
|
||||
storePath: params.storePath,
|
||||
};
|
||||
return sessionId ? { ...params, sessionId } : null;
|
||||
}
|
||||
|
||||
function selectSessionsToTail(selections: TailSelection[], sessionKey?: string): TailSelection[] {
|
||||
|
|
@ -238,10 +205,10 @@ function selectSessionsToTail(selections: TailSelection[], sessionKey?: string):
|
|||
|
||||
function readNewSqliteFollowEvents(state: SqliteFollowState): TrajectoryEvent[] {
|
||||
const rows = loadSqliteTrajectoryRuntimeEventRowsSync({
|
||||
agentId: state.selection.source.agentId,
|
||||
agentId: state.selection.agentId,
|
||||
afterSeq: state.lastStorageSeq,
|
||||
sessionId: state.selection.source.sessionId,
|
||||
storePath: state.selection.source.storePath,
|
||||
sessionId: state.selection.sessionId,
|
||||
storePath: state.selection.storePath,
|
||||
});
|
||||
if (rows.length === 0) {
|
||||
return [];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue