diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 12fdb0c4f43..b44a368597d 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -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); diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts index 2e76e1a5de1..ca1e2c09402 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts @@ -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); diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index 03d021e1bf0..caffac23557 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -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[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; diff --git a/ui/src/ui/app-render.helpers.node.test.ts b/ui/src/ui/app-render.helpers.node.test.ts index 72f39209be3..3004c0e8549 100644 --- a/ui/src/ui/app-render.helpers.node.test.ts +++ b/ui/src/ui/app-render.helpers.node.test.ts @@ -1,9 +1,29 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; + +vi.hoisted(() => { + const storage = new Map(); + 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 & { key: string }): SessionRow { return { kind: "direct", updatedAt: 0, ...overrides }; } +function testState(overrides: Partial = {}): 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", + ]); + }); +}); diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 8e212951a3f..c32a31c4370 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -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(); 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); diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 98699d3b567..c2b7340d1b6 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -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, diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 8fa6ccac14f..615150c7850 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -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; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 8bf039f18e6..7b74ccb38de 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -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; diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index ba72e647732..49da2460652 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -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; diff --git a/ui/src/ui/views/overview-hints.ts b/ui/src/ui/views/overview-hints.ts index 9db33a2b577..fa661016464 100644 --- a/ui/src/ui/views/overview-hints.ts +++ b/ui/src/ui/views/overview-hints.ts @@ -1,5 +1,31 @@ import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js"; +const AUTH_REQUIRED_CODES = new Set([ + 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([ + ...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([ + 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"); +} diff --git a/ui/src/ui/views/overview.node.test.ts b/ui/src/ui/views/overview.node.test.ts index 3fa65b93391..a5657c8de8f 100644 --- a/ui/src/ui/views/overview.node.test.ts +++ b/ui/src/ui/views/overview.node.test.ts @@ -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); + }); }); }); diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts index 34c2ffc3f27..4b9ea9bb493 100644 --- a/ui/src/ui/views/overview.ts +++ b/ui/src/ui/views/overview.ts @@ -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`
${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`