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:
Peter Steinberger 2026-09-02 09:22:17 -07:00 • committed by GitHub
parent f1ae53b736
commit 1623683f47
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 74 additions and 61 deletions

View file

@ -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

View file

@ -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", "中文", "中文"],

View file

@ -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 [];