feat: enhance chat history and error handling in UI

- Added a test to ensure chat.history preserves usage and cost metadata for assistant messages.
- Updated chat message sanitization to retain usage and cost information for UI rendering.
- Enhanced the AppViewState and UI components to include lastErrorCode for improved error handling.
- Implemented new utility functions in overview hints to manage authentication and context errors.
- Updated tests to cover new functionality and ensure correct behavior in various scenarios.
This commit is contained in:
Val Alexander 2026-03-05 18:15:41 -06:00
parent cfec9a268a
commit 1e440712fb
No known key found for this signature in database
12 changed files with 292 additions and 41 deletions

View File

@ -180,6 +180,8 @@ function sanitizeChatHistoryMessage(message: unknown): { message: unknown; chang
changed = true;
}
// Keep usage/cost so the chat UI can render per-message token and cost badges.
if (typeof entry.content === "string") {
const stripped = stripInlineDirectiveTagsForDisplay(entry.content);
const res = truncateChatHistoryText(stripped.text);

View File

@ -273,6 +273,37 @@ describe("gateway server chat", () => {
});
});
test("chat.history preserves usage and cost metadata for assistant messages", async () => {
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
await connectOk(ws);
const sessionDir = await createSessionDir();
await writeMainSessionStore();
await writeMainSessionTranscript(sessionDir, [
JSON.stringify({
message: {
role: "assistant",
timestamp: Date.now(),
content: [{ type: "text", text: "hello" }],
usage: { input: 12, output: 5, totalTokens: 17 },
cost: { total: 0.0123 },
details: { debug: true },
},
}),
]);
const messages = await fetchHistoryMessages(ws);
expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({
role: "assistant",
usage: { input: 12, output: 5, totalTokens: 17 },
cost: { total: 0.0123 },
});
expect(messages[0]).not.toHaveProperty("details");
});
});
test("chat.history strips inline directives from displayed message text", async () => {
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
await connectOk(ws);

View File

@ -28,7 +28,11 @@ import {
import { loadHealthState } from "./controllers/health.ts";
import { loadNodes } from "./controllers/nodes.ts";
import { loadSessions } from "./controllers/sessions.ts";
import type { GatewayEventFrame, GatewayHelloOk } from "./gateway.ts";
import {
resolveGatewayErrorDetailCode,
type GatewayEventFrame,
type GatewayHelloOk,
} from "./gateway.ts";
import { GatewayBrowserClient } from "./gateway.ts";
import type { Tab } from "./navigation.ts";
import type { UiSettings } from "./storage.ts";
@ -43,10 +47,12 @@ import type {
type GatewayHost = {
settings: UiSettings;
password: string;
clientInstanceId: string;
client: GatewayBrowserClient | null;
connected: boolean;
hello: GatewayHelloOk | null;
lastError: string | null;
lastErrorCode: string | null;
onboarding?: boolean;
eventLogBuffer: EventLogEntry[];
eventLog: EventLogEntry[];
@ -161,6 +167,7 @@ function applySessionDefaults(host: GatewayHost, defaults?: SessionDefaultsSnaps
export function connectGateway(host: GatewayHost) {
host.lastError = null;
host.lastErrorCode = null;
host.hello = null;
host.connected = false;
host.execApprovalQueue = [];
@ -178,12 +185,14 @@ export function connectGateway(host: GatewayHost) {
clientName: "openclaw-control-ui",
clientVersion,
mode: "webchat",
instanceId: host.clientInstanceId,
onHello: (hello) => {
if (host.client !== client) {
return;
}
host.connected = true;
host.lastError = null;
host.lastErrorCode = null;
host.hello = hello;
applySnapshot(host, hello);
// Reset orphaned chat run state from before disconnect.
@ -199,14 +208,24 @@ export function connectGateway(host: GatewayHost) {
void loadDevices(host as unknown as OpenClawApp, { quiet: true });
void refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
},
onClose: ({ code, reason }) => {
onClose: ({ code, reason, error }) => {
if (host.client !== client) {
return;
}
host.connected = false;
// Code 1012 = Service Restart (expected during config saves, don't show as error)
host.lastErrorCode =
resolveGatewayErrorDetailCode(error) ??
(typeof error?.code === "string" ? error.code : null);
if (code !== 1012) {
if (error?.message) {
host.lastError = error.message;
return;
}
host.lastError = `disconnected (${code}): ${reason || "no reason"}`;
} else {
host.lastError = null;
host.lastErrorCode = null;
}
},
onEvent: (evt) => {
@ -220,6 +239,7 @@ export function connectGateway(host: GatewayHost) {
return;
}
host.lastError = `event gap detected (expected seq ${expected}, got ${received}); refresh recommended`;
host.lastErrorCode = null;
},
});
host.client = client;

View File

@ -1,9 +1,29 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
vi.hoisted(() => {
const storage = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
clear: () => storage.clear(),
},
});
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: { language: "en-US" },
});
return {};
});
import {
isCronSessionKey,
parseSessionKey,
resolveSessionOptionGroups,
resolveSessionDisplayName,
} from "./app-render.helpers.ts";
import type { AppViewState } from "./app-view-state.ts";
import type { SessionsListResult } from "./types.ts";
type SessionRow = SessionsListResult["sessions"][number];
@ -12,6 +32,14 @@ function row(overrides: Partial<SessionRow> & { key: string }): SessionRow {
return { kind: "direct", updatedAt: 0, ...overrides };
}
function testState(overrides: Partial<AppViewState> = {}): AppViewState {
return {
agentsList: null,
sessionsHideCron: true,
...overrides,
} as AppViewState;
}
/* ================================================================
* parseSessionKey low-level key type / fallback mapping
* ================================================================ */
@ -284,3 +312,40 @@ describe("isCronSessionKey", () => {
expect(isCronSessionKey("agent:main:slack:cron:job:run:uuid")).toBe(false);
});
});
describe("resolveSessionOptionGroups", () => {
const sessions: SessionsListResult = {
sessions: [
row({ key: "agent:main:main" }),
row({ key: "agent:main:cron:daily" }),
row({ key: "agent:main:discord:direct:user-1" }),
],
};
it("filters cron sessions from options when the hide toggle is enabled", () => {
const groups = resolveSessionOptionGroups(
testState({ sessionsHideCron: true }),
"agent:main:main",
sessions,
);
expect(groups.flatMap((group) => group.options.map((option) => option.key))).toEqual([
"agent:main:main",
"agent:main:discord:direct:user-1",
]);
});
it("retains the active cron session even when cron sessions are hidden", () => {
const groups = resolveSessionOptionGroups(
testState({ sessionsHideCron: true }),
"agent:main:cron:daily",
sessions,
);
expect(groups.flatMap((group) => group.options.map((option) => option.key))).toEqual([
"agent:main:main",
"agent:main:cron:daily",
"agent:main:discord:direct:user-1",
]);
});
});

View File

@ -452,12 +452,13 @@ type SessionOptionGroup = {
options: SessionOptionEntry[];
};
function resolveSessionOptionGroups(
export function resolveSessionOptionGroups(
state: AppViewState,
sessionKey: string,
sessions: SessionsListResult | null,
): SessionOptionGroup[] {
const rows = sessions?.sessions ?? [];
const hideCron = state.sessionsHideCron ?? true;
const byKey = new Map<string, SessionsListResult["sessions"][number]>();
for (const row of rows) {
byKey.set(row.key, row);
@ -501,6 +502,9 @@ function resolveSessionOptionGroups(
};
for (const row of rows) {
if (hideCron && row.key !== sessionKey && isCronSessionKey(row.key)) {
continue;
}
addOption(row.key);
}
addOption(sessionKey);

View File

@ -565,6 +565,7 @@ export function renderApp(state: AppViewState) {
settings: state.settings,
password: state.password,
lastError: state.lastError,
lastErrorCode: state.lastErrorCode,
presenceCount,
sessionsCount,
cronEnabled: state.cronStatus?.enabled ?? null,

View File

@ -51,6 +51,7 @@ export type AppViewState = {
themeOrder: ThemeName[];
hello: GatewayHelloOk | null;
lastError: string | null;
lastErrorCode: string | null;
eventLog: EventLogEntry[];
assistantName: string;
assistantAvatar: string | null;

View File

@ -153,6 +153,7 @@ export class OpenClawApp extends LitElement {
@state() themeOrder: ThemeName[] = this.buildThemeOrder(this.theme);
@state() hello: GatewayHelloOk | null = null;
@state() lastError: string | null = null;
@state() lastErrorCode: string | null = null;
@state() eventLog: EventLogEntry[] = [];
private eventLogBuffer: EventLogEntry[] = [];
private toolStreamSyncTimer: number | null = null;

View File

@ -413,7 +413,7 @@ export type {
export type CronSchedule =
| { kind: "at"; at: string }
| { kind: "every"; everyMs: number; anchorMs?: number }
| { kind: "cron"; expr: string; tz?: string };
| { kind: "cron"; expr: string; tz?: string; staggerMs?: number };
export type CronSessionTarget = "main" | "isolated";
export type CronWakeMode = "next-heartbeat" | "now";
@ -423,6 +423,7 @@ export type CronPayload =
| {
kind: "agentTurn";
message: string;
model?: string;
thinking?: string;
timeoutSeconds?: number;
lightContext?: boolean;

View File

@ -1,5 +1,31 @@
import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js";
const AUTH_REQUIRED_CODES = new Set<string>([
ConnectErrorDetailCodes.AUTH_REQUIRED,
ConnectErrorDetailCodes.AUTH_TOKEN_MISSING,
ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING,
ConnectErrorDetailCodes.AUTH_TOKEN_NOT_CONFIGURED,
ConnectErrorDetailCodes.AUTH_PASSWORD_NOT_CONFIGURED,
]);
const AUTH_FAILURE_CODES = new Set<string>([
...AUTH_REQUIRED_CODES,
ConnectErrorDetailCodes.AUTH_UNAUTHORIZED,
ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH,
ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH,
ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH,
ConnectErrorDetailCodes.AUTH_RATE_LIMITED,
ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISSING,
ConnectErrorDetailCodes.AUTH_TAILSCALE_PROXY_MISSING,
ConnectErrorDetailCodes.AUTH_TAILSCALE_WHOIS_FAILED,
ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISMATCH,
]);
const INSECURE_CONTEXT_CODES = new Set<string>([
ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED,
ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED,
]);
/** Whether the overview should show device-pairing guidance for this error. */
export function shouldShowPairingHint(
connected: boolean,
@ -14,3 +40,44 @@ export function shouldShowPairingHint(
}
return lastError.toLowerCase().includes("pairing required");
}
export function shouldShowAuthHint(
connected: boolean,
lastError: string | null,
lastErrorCode?: string | null,
): boolean {
if (connected || !lastError) {
return false;
}
if (lastErrorCode) {
return AUTH_FAILURE_CODES.has(lastErrorCode);
}
const lower = lastError.toLowerCase();
return lower.includes("unauthorized") || lower.includes("connect failed");
}
export function shouldShowAuthRequiredHint(
hasToken: boolean,
hasPassword: boolean,
lastErrorCode?: string | null,
): boolean {
if (lastErrorCode) {
return AUTH_REQUIRED_CODES.has(lastErrorCode);
}
return !hasToken && !hasPassword;
}
export function shouldShowInsecureContextHint(
connected: boolean,
lastError: string | null,
lastErrorCode?: string | null,
): boolean {
if (connected || !lastError) {
return false;
}
if (lastErrorCode) {
return INSECURE_CONTEXT_CODES.has(lastErrorCode);
}
const lower = lastError.toLowerCase();
return lower.includes("secure context") || lower.includes("device identity required");
}

View File

@ -1,39 +1,95 @@
import { describe, expect, it } from "vitest";
import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js";
import { shouldShowPairingHint } from "./overview-hints.ts";
import {
shouldShowAuthHint,
shouldShowAuthRequiredHint,
shouldShowInsecureContextHint,
shouldShowPairingHint,
} from "./overview-hints.ts";
describe("shouldShowPairingHint", () => {
it("returns true for 'pairing required' close reason", () => {
expect(shouldShowPairingHint(false, "disconnected (1008): pairing required")).toBe(true);
describe("overview hints", () => {
describe("shouldShowPairingHint", () => {
it("returns true for 'pairing required' close reason", () => {
expect(shouldShowPairingHint(false, "disconnected (1008): pairing required")).toBe(true);
});
it("matches case-insensitively", () => {
expect(shouldShowPairingHint(false, "Pairing Required")).toBe(true);
});
it("returns false when connected", () => {
expect(shouldShowPairingHint(true, "disconnected (1008): pairing required")).toBe(false);
});
it("returns false when lastError is null", () => {
expect(shouldShowPairingHint(false, null)).toBe(false);
});
it("returns false for unrelated errors", () => {
expect(shouldShowPairingHint(false, "disconnected (1006): no reason")).toBe(false);
});
it("returns false for auth errors", () => {
expect(shouldShowPairingHint(false, "disconnected (4008): unauthorized")).toBe(false);
});
it("returns true for structured pairing code", () => {
expect(
shouldShowPairingHint(
false,
"disconnected (4008): connect failed",
ConnectErrorDetailCodes.PAIRING_REQUIRED,
),
).toBe(true);
});
});
it("matches case-insensitively", () => {
expect(shouldShowPairingHint(false, "Pairing Required")).toBe(true);
describe("shouldShowAuthHint", () => {
it("returns true for structured auth failures", () => {
expect(
shouldShowAuthHint(
false,
"disconnected (4008): connect failed",
ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISMATCH,
),
).toBe(true);
});
it("falls back to legacy close text when no detail code is present", () => {
expect(shouldShowAuthHint(false, "disconnected (4008): unauthorized")).toBe(true);
});
it("returns false for non-auth errors", () => {
expect(shouldShowAuthHint(false, "disconnected (1006): no reason")).toBe(false);
});
});
it("returns false when connected", () => {
expect(shouldShowPairingHint(true, "disconnected (1008): pairing required")).toBe(false);
describe("shouldShowAuthRequiredHint", () => {
it("returns true for structured auth-required codes", () => {
expect(
shouldShowAuthRequiredHint(true, true, ConnectErrorDetailCodes.AUTH_TOKEN_MISSING),
).toBe(true);
});
it("falls back to missing credentials when detail code is absent", () => {
expect(shouldShowAuthRequiredHint(false, false, null)).toBe(true);
expect(shouldShowAuthRequiredHint(true, false, null)).toBe(false);
});
});
it("returns false when lastError is null", () => {
expect(shouldShowPairingHint(false, null)).toBe(false);
});
describe("shouldShowInsecureContextHint", () => {
it("returns true for structured device identity errors", () => {
expect(
shouldShowInsecureContextHint(
false,
"disconnected (4008): connect failed",
ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED,
),
).toBe(true);
});
it("returns false for unrelated errors", () => {
expect(shouldShowPairingHint(false, "disconnected (1006): no reason")).toBe(false);
});
it("returns false for auth errors", () => {
expect(shouldShowPairingHint(false, "disconnected (4008): unauthorized")).toBe(false);
});
it("returns true for structured pairing code", () => {
expect(
shouldShowPairingHint(
false,
"disconnected (4008): connect failed",
ConnectErrorDetailCodes.PAIRING_REQUIRED,
),
).toBe(true);
it("falls back to legacy close text when detail code is absent", () => {
expect(shouldShowInsecureContextHint(false, "device identity required")).toBe(true);
});
});
});

View File

@ -1,5 +1,4 @@
import { html, nothing } from "lit";
import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js";
import { t, i18n, SUPPORTED_LOCALES, type Locale } from "../../i18n/index.ts";
import type { EventLogEntry } from "../app-events.ts";
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../external-link.ts";
@ -18,7 +17,12 @@ import type {
import { renderOverviewAttention } from "./overview-attention.ts";
import { renderOverviewCards } from "./overview-cards.ts";
import { renderOverviewEventLog } from "./overview-event-log.ts";
import { shouldShowPairingHint } from "./overview-hints.ts";
import {
shouldShowAuthHint,
shouldShowAuthRequiredHint,
shouldShowInsecureContextHint,
shouldShowPairingHint,
} from "./overview-hints.ts";
import { renderOverviewLogTail } from "./overview-log-tail.ts";
export type OverviewProps = {
@ -27,6 +31,7 @@ export type OverviewProps = {
settings: UiSettings;
password: string;
lastError: string | null;
lastErrorCode: string | null;
presenceCount: number;
sessionsCount: number | null;
cronEnabled: boolean | null;
@ -72,7 +77,7 @@ export function renderOverview(props: OverviewProps) {
const isTrustedProxy = authMode === "trusted-proxy";
const pairingHint = (() => {
if (!shouldShowPairingHint(props.connected, props.lastError)) {
if (!shouldShowPairingHint(props.connected, props.lastError, props.lastErrorCode)) {
return null;
}
return html`
@ -103,14 +108,12 @@ export function renderOverview(props: OverviewProps) {
if (props.connected || !props.lastError) {
return null;
}
const lower = props.lastError.toLowerCase();
const authFailed = lower.includes("unauthorized") || lower.includes("connect failed");
if (!authFailed) {
if (!shouldShowAuthHint(props.connected, props.lastError, props.lastErrorCode)) {
return null;
}
const hasToken = Boolean(props.settings.token.trim());
const hasPassword = Boolean(props.password.trim());
if (!hasToken && !hasPassword) {
if (shouldShowAuthRequiredHint(hasToken, hasPassword, props.lastErrorCode)) {
return html`
<div class="muted" style="margin-top: 8px">
${t("overview.auth.required")}
@ -156,8 +159,7 @@ export function renderOverview(props: OverviewProps) {
if (isSecureContext) {
return null;
}
const lower = props.lastError.toLowerCase();
if (!lower.includes("secure context") && !lower.includes("device identity required")) {
if (!shouldShowInsecureContextHint(props.connected, props.lastError, props.lastErrorCode)) {
return null;
}
return html`