From de4c3db3e38a14d90d8ce3730e6ef83a1b79881e Mon Sep 17 00:00:00 2001 From: Altay Date: Tue, 10 Mar 2026 01:40:15 +0300 Subject: [PATCH 01/11] Logging: harden probe suppression for observations (#41338) Merged via squash. Prepared head SHA: d18356cb8062935090466d4e142ce202381d4ef2 Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + src/logging/subsystem.test.ts | 118 +++++++++++++++++++++++++++++++++- src/logging/subsystem.ts | 47 +++++++++++--- 3 files changed, 157 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 534922abe57..40fafa21920 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai - Agents/fallback observability: add structured, sanitized model-fallback decision and auth-profile failure-state events with correlated run IDs so cooldown probes and failover paths are easier to trace in logs. (#41337) thanks @altaywtf. - Protocol/Swift model sync: regenerate pending node work Swift bindings after the landed `node.pending.*` schema additions so generated protocol artifacts are consistent again. (#41477) Thanks @mbelinky. - Discord/reply chunking: resolve the effective `maxLinesPerMessage` config across live reply paths and preserve `chunkMode` in the fast send path so long Discord replies no longer split unexpectedly at the default 17-line limit. (#40133) thanks @rbutera. +- Logging/probe observations: suppress structured embedded and model-fallback probe warnings on the console without hiding error or fatal output. (#41338) thanks @altaywtf. ## 2026.3.8 diff --git a/src/logging/subsystem.test.ts b/src/logging/subsystem.test.ts index e389d78ba8a..06f504f47de 100644 --- a/src/logging/subsystem.test.ts +++ b/src/logging/subsystem.test.ts @@ -1,11 +1,13 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { setConsoleSubsystemFilter } from "./console.js"; import { resetLogger, setLoggerOverride } from "./logger.js"; +import { loggingState } from "./state.js"; import { createSubsystemLogger } from "./subsystem.js"; afterEach(() => { setConsoleSubsystemFilter(null); setLoggerOverride(null); + loggingState.rawConsole = null; resetLogger(); }); @@ -53,4 +55,118 @@ describe("createSubsystemLogger().isEnabled", () => { expect(log.isEnabled("info", "file")).toBe(true); expect(log.isEnabled("info")).toBe(true); }); + + it("suppresses probe warnings for embedded subsystems based on structured run metadata", () => { + setLoggerOverride({ level: "silent", consoleLevel: "warn" }); + const warn = vi.fn(); + loggingState.rawConsole = { + log: vi.fn(), + info: vi.fn(), + warn, + error: vi.fn(), + }; + const log = createSubsystemLogger("agent/embedded").child("failover"); + + log.warn("embedded run failover decision", { + runId: "probe-test-run", + consoleMessage: "embedded run failover decision", + }); + + expect(warn).not.toHaveBeenCalled(); + }); + + it("does not suppress probe errors for embedded subsystems", () => { + setLoggerOverride({ level: "silent", consoleLevel: "error" }); + const error = vi.fn(); + loggingState.rawConsole = { + log: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error, + }; + const log = createSubsystemLogger("agent/embedded").child("failover"); + + log.error("embedded run failover decision", { + runId: "probe-test-run", + consoleMessage: "embedded run failover decision", + }); + + expect(error).toHaveBeenCalledTimes(1); + }); + + it("suppresses probe warnings for model-fallback child subsystems based on structured run metadata", () => { + setLoggerOverride({ level: "silent", consoleLevel: "warn" }); + const warn = vi.fn(); + loggingState.rawConsole = { + log: vi.fn(), + info: vi.fn(), + warn, + error: vi.fn(), + }; + const log = createSubsystemLogger("model-fallback").child("decision"); + + log.warn("model fallback decision", { + runId: "probe-test-run", + consoleMessage: "model fallback decision", + }); + + expect(warn).not.toHaveBeenCalled(); + }); + + it("does not suppress probe errors for model-fallback child subsystems", () => { + setLoggerOverride({ level: "silent", consoleLevel: "error" }); + const error = vi.fn(); + loggingState.rawConsole = { + log: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error, + }; + const log = createSubsystemLogger("model-fallback").child("decision"); + + log.error("model fallback decision", { + runId: "probe-test-run", + consoleMessage: "model fallback decision", + }); + + expect(error).toHaveBeenCalledTimes(1); + }); + + it("still emits non-probe warnings for embedded subsystems", () => { + setLoggerOverride({ level: "silent", consoleLevel: "warn" }); + const warn = vi.fn(); + loggingState.rawConsole = { + log: vi.fn(), + info: vi.fn(), + warn, + error: vi.fn(), + }; + const log = createSubsystemLogger("agent/embedded").child("auth-profiles"); + + log.warn("auth profile failure state updated", { + runId: "run-123", + consoleMessage: "auth profile failure state updated", + }); + + expect(warn).toHaveBeenCalledTimes(1); + }); + + it("still emits non-probe model-fallback child warnings", () => { + setLoggerOverride({ level: "silent", consoleLevel: "warn" }); + const warn = vi.fn(); + loggingState.rawConsole = { + log: vi.fn(), + info: vi.fn(), + warn, + error: vi.fn(), + }; + const log = createSubsystemLogger("model-fallback").child("decision"); + + log.warn("model fallback decision", { + runId: "run-123", + consoleMessage: "model fallback decision", + }); + + expect(warn).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/logging/subsystem.ts b/src/logging/subsystem.ts index 18be000e9ba..5c6ce58a43d 100644 --- a/src/logging/subsystem.ts +++ b/src/logging/subsystem.ts @@ -250,6 +250,38 @@ function writeConsoleLine(level: LogLevel, line: string) { } } +function shouldSuppressProbeConsoleLine(params: { + level: LogLevel; + subsystem: string; + message: string; + meta?: Record; +}): boolean { + if (isVerbose()) { + return false; + } + if (params.level === "error" || params.level === "fatal") { + return false; + } + const isProbeSuppressedSubsystem = + params.subsystem === "agent/embedded" || + params.subsystem.startsWith("agent/embedded/") || + params.subsystem === "model-fallback" || + params.subsystem.startsWith("model-fallback/"); + if (!isProbeSuppressedSubsystem) { + return false; + } + const runLikeId = + typeof params.meta?.runId === "string" + ? params.meta.runId + : typeof params.meta?.sessionId === "string" + ? params.meta.sessionId + : undefined; + if (runLikeId?.startsWith("probe-")) { + return true; + } + return /(sessionId|runId)=probe-/.test(params.message); +} + function logToFile( fileLogger: TsLogger, level: LogLevel, @@ -309,9 +341,12 @@ export function createSubsystemLogger(subsystem: string): SubsystemLogger { } const consoleMessage = consoleMessageOverride ?? message; if ( - !isVerbose() && - subsystem === "agent/embedded" && - /(sessionId|runId)=probe-/.test(consoleMessage) + shouldSuppressProbeConsoleLine({ + level, + subsystem, + message: consoleMessage, + meta: fileMeta, + }) ) { return; } @@ -355,11 +390,7 @@ export function createSubsystemLogger(subsystem: string): SubsystemLogger { logToFile(getFileLogger(), "info", message, { raw: true }); } if (isConsoleEnabled("info")) { - if ( - !isVerbose() && - subsystem === "agent/embedded" && - /(sessionId|runId)=probe-/.test(message) - ) { + if (shouldSuppressProbeConsoleLine({ level: "info", subsystem, message })) { return; } writeConsoleLine("info", message); From c9a6c542ef7ae9350fd79e20a7e6642b5ce4d604 Mon Sep 17 00:00:00 2001 From: alan blount Date: Mon, 9 Mar 2026 18:55:10 -0400 Subject: [PATCH 02/11] Add HTTP 499 to transient error codes for model fallback (#41468) Merged via squash. Prepared head SHA: 0053bae14038e6df9264df364d1c9aa83d5b698e Co-authored-by: zeroasterisk <23422+zeroasterisk@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + src/agents/failover-error.test.ts | 7 +++++++ ...-embedded-helpers.isbillingerrormessage.test.ts | 14 ++++++++++++++ src/agents/pi-embedded-helpers/errors.ts | 8 +++++++- 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40fafa21920..48ec8f44552 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai - Protocol/Swift model sync: regenerate pending node work Swift bindings after the landed `node.pending.*` schema additions so generated protocol artifacts are consistent again. (#41477) Thanks @mbelinky. - Discord/reply chunking: resolve the effective `maxLinesPerMessage` config across live reply paths and preserve `chunkMode` in the fast send path so long Discord replies no longer split unexpectedly at the default 17-line limit. (#40133) thanks @rbutera. - Logging/probe observations: suppress structured embedded and model-fallback probe warnings on the console without hiding error or fatal output. (#41338) thanks @altaywtf. +- Agents/fallback: treat HTTP 499 responses as transient in both raw-text and structured failover paths so Anthropic-style client-closed overload responses trigger model fallback reliably. (#41468) thanks @zeroasterisk. ## 2026.3.8 diff --git a/src/agents/failover-error.test.ts b/src/agents/failover-error.test.ts index a99cfb5c4b2..db01c03d8c4 100644 --- a/src/agents/failover-error.test.ts +++ b/src/agents/failover-error.test.ts @@ -67,6 +67,7 @@ describe("failover-error", () => { expect(resolveFailoverReasonFromError({ statusCode: "429" })).toBe("rate_limit"); expect(resolveFailoverReasonFromError({ status: 403 })).toBe("auth"); expect(resolveFailoverReasonFromError({ status: 408 })).toBe("timeout"); + expect(resolveFailoverReasonFromError({ status: 499 })).toBe("timeout"); expect(resolveFailoverReasonFromError({ status: 400 })).toBe("format"); // Keep the status-only path behavior-preserving and conservative. expect(resolveFailoverReasonFromError({ status: 500 })).toBeNull(); @@ -93,6 +94,12 @@ describe("failover-error", () => { message: ANTHROPIC_OVERLOADED_PAYLOAD, }), ).toBe("overloaded"); + expect( + resolveFailoverReasonFromError({ + status: 499, + message: ANTHROPIC_OVERLOADED_PAYLOAD, + }), + ).toBe("overloaded"); expect( resolveFailoverReasonFromError({ status: 429, diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index 86fd90e7161..f60a127a0ab 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -443,6 +443,7 @@ describe("isLikelyContextOverflowError", () => { describe("isTransientHttpError", () => { it("returns true for retryable 5xx status codes", () => { + expect(isTransientHttpError("499 Client Closed Request")).toBe(true); expect(isTransientHttpError("500 Internal Server Error")).toBe(true); expect(isTransientHttpError("502 Bad Gateway")).toBe(true); expect(isTransientHttpError("503 Service Unavailable")).toBe(true); @@ -457,6 +458,19 @@ describe("isTransientHttpError", () => { }); }); +describe("classifyFailoverReasonFromHttpStatus", () => { + it("treats HTTP 499 as transient for structured errors", () => { + expect(classifyFailoverReasonFromHttpStatus(499)).toBe("timeout"); + expect(classifyFailoverReasonFromHttpStatus(499, "499 Client Closed Request")).toBe("timeout"); + expect( + classifyFailoverReasonFromHttpStatus( + 499, + '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}', + ), + ).toBe("overloaded"); + }); +}); + describe("isFailoverErrorMessage", () => { it("matches auth/rate/billing/timeout", () => { const samples = [ diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 4cf347150bf..9ab52c04355 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -189,7 +189,7 @@ const HTTP_STATUS_PREFIX_RE = /^(?:http\s*)?(\d{3})\s+(.+)$/i; const HTTP_STATUS_CODE_PREFIX_RE = /^(?:http\s*)?(\d{3})(?:\s+([\s\S]+))?$/i; const HTML_ERROR_PREFIX_RE = /^\s*(?: Date: Tue, 10 Mar 2026 00:07:26 +0100 Subject: [PATCH 03/11] fix(plugins): expose model auth API to context-engine plugins (#41090) Merged via squash. Prepared head SHA: ee96e96bb984cc3e1e152d17199357a8f6db312d Co-authored-by: xinhuagu <562450+xinhuagu@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman --- CHANGELOG.md | 1 + extensions/test-utils/plugin-runtime-mock.ts | 5 +++++ src/plugin-sdk/index.ts | 6 ++++++ src/plugins/runtime/index.test.ts | 17 +++++++++++++++ src/plugins/runtime/index.ts | 22 ++++++++++++++++++++ src/plugins/runtime/types-core.ts | 12 +++++++++++ 6 files changed, 63 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48ec8f44552..9f705ed77a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ Docs: https://docs.openclaw.ai - Discord/reply chunking: resolve the effective `maxLinesPerMessage` config across live reply paths and preserve `chunkMode` in the fast send path so long Discord replies no longer split unexpectedly at the default 17-line limit. (#40133) thanks @rbutera. - Logging/probe observations: suppress structured embedded and model-fallback probe warnings on the console without hiding error or fatal output. (#41338) thanks @altaywtf. - Agents/fallback: treat HTTP 499 responses as transient in both raw-text and structured failover paths so Anthropic-style client-closed overload responses trigger model fallback reliably. (#41468) thanks @zeroasterisk. +- Plugins/context-engine model auth: expose `runtime.modelAuth` and plugin-sdk auth helpers so plugins can resolve provider/model API keys through the normal auth pipeline. (#41090) thanks @xinhuagu. ## 2026.3.8 diff --git a/extensions/test-utils/plugin-runtime-mock.ts b/extensions/test-utils/plugin-runtime-mock.ts index 8c599599a31..81e3fdedeec 100644 --- a/extensions/test-utils/plugin-runtime-mock.ts +++ b/extensions/test-utils/plugin-runtime-mock.ts @@ -253,6 +253,11 @@ export function createPluginRuntimeMock(overrides: DeepPartial = state: { resolveStateDir: vi.fn(() => "/tmp/openclaw"), }, + modelAuth: { + getApiKeyForModel: vi.fn() as unknown as PluginRuntime["modelAuth"]["getApiKeyForModel"], + resolveApiKeyForProvider: + vi.fn() as unknown as PluginRuntime["modelAuth"]["resolveApiKeyForProvider"], + }, subagent: { run: vi.fn(), waitForRun: vi.fn(), diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 3e1ba0f03ab..35709dc4fec 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -801,5 +801,11 @@ export type { export { registerContextEngine } from "../context-engine/registry.js"; export type { ContextEngineFactory } from "../context-engine/registry.js"; +// Model authentication types for plugins. +// Plugins should use runtime.modelAuth (which strips unsafe overrides like +// agentDir/store) rather than importing raw helpers directly. +export { requireApiKey } from "../agents/model-auth.js"; +export type { ResolvedProviderAuth } from "../agents/model-auth.js"; + // Security utilities export { redactSensitiveText } from "../logging/redact.js"; diff --git a/src/plugins/runtime/index.test.ts b/src/plugins/runtime/index.test.ts index 77b3de66062..5ec2df28199 100644 --- a/src/plugins/runtime/index.test.ts +++ b/src/plugins/runtime/index.test.ts @@ -53,4 +53,21 @@ describe("plugin runtime command execution", () => { const runtime = createPluginRuntime(); expect(runtime.system.requestHeartbeatNow).toBe(requestHeartbeatNow); }); + + it("exposes runtime.modelAuth with getApiKeyForModel and resolveApiKeyForProvider", () => { + const runtime = createPluginRuntime(); + expect(runtime.modelAuth).toBeDefined(); + expect(typeof runtime.modelAuth.getApiKeyForModel).toBe("function"); + expect(typeof runtime.modelAuth.resolveApiKeyForProvider).toBe("function"); + }); + + it("modelAuth wrappers strip agentDir and store to prevent credential steering", async () => { + // The wrappers should not forward agentDir or store from plugin callers. + // We verify this by checking the wrapper functions exist and are not the + // raw implementations (they are wrapped, not direct references). + const { getApiKeyForModel: rawGetApiKey } = await import("../../agents/model-auth.js"); + const runtime = createPluginRuntime(); + // Wrappers should NOT be the same reference as the raw functions + expect(runtime.modelAuth.getApiKeyForModel).not.toBe(rawGetApiKey); + }); }); diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index 68b672db1b4..12d33168cd3 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -1,4 +1,8 @@ import { createRequire } from "node:module"; +import { + getApiKeyForModel as getApiKeyForModelRaw, + resolveApiKeyForProvider as resolveApiKeyForProviderRaw, +} from "../../agents/model-auth.js"; import { resolveStateDir } from "../../config/paths.js"; import { transcribeAudioFile } from "../../media-understanding/transcribe-audio.js"; import { textToSpeechTelephony } from "../../tts/tts.js"; @@ -59,6 +63,24 @@ export function createPluginRuntime(_options: CreatePluginRuntimeOptions = {}): events: createRuntimeEvents(), logging: createRuntimeLogging(), state: { resolveStateDir }, + modelAuth: { + // Wrap model-auth helpers so plugins cannot steer credential lookups: + // - agentDir / store: stripped (prevents reading other agents' stores) + // - profileId / preferredProfile: stripped (prevents cross-provider + // credential access via profile steering) + // Plugins only specify provider/model; the core auth pipeline picks + // the appropriate credential automatically. + getApiKeyForModel: (params) => + getApiKeyForModelRaw({ + model: params.model, + cfg: params.cfg, + }), + resolveApiKeyForProvider: (params) => + resolveApiKeyForProviderRaw({ + provider: params.provider, + cfg: params.cfg, + }), + }, } satisfies PluginRuntime; return runtime; diff --git a/src/plugins/runtime/types-core.ts b/src/plugins/runtime/types-core.ts index 524b3a5f6a2..bfbb747c9c4 100644 --- a/src/plugins/runtime/types-core.ts +++ b/src/plugins/runtime/types-core.ts @@ -52,4 +52,16 @@ export type PluginRuntimeCore = { state: { resolveStateDir: typeof import("../../config/paths.js").resolveStateDir; }; + modelAuth: { + /** Resolve auth for a model. Only provider/model and optional cfg are used. */ + getApiKeyForModel: (params: { + model: import("@mariozechner/pi-ai").Model; + cfg?: import("../../config/config.js").OpenClawConfig; + }) => Promise; + /** Resolve auth for a provider by name. Only provider and optional cfg are used. */ + resolveApiKeyForProvider: (params: { + provider: string; + cfg?: import("../../config/config.js").OpenClawConfig; + }) => Promise; + }; }; From b48291e01eca26a5b04ea1d6219c13b4437c3ead Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 9 Mar 2026 16:14:08 -0700 Subject: [PATCH 04/11] Exec: mark child command env with OPENCLAW_CLI (#41411) --- src/agents/sandbox-create-args.test.ts | 10 +++++++++- src/agents/sandbox/docker.ts | 3 ++- src/entry.ts | 2 ++ src/infra/host-env-security.test.ts | 5 +++++ src/infra/host-env-security.ts | 5 +++-- src/infra/openclaw-exec-env.ts | 16 ++++++++++++++++ src/process/exec.test.ts | 2 ++ src/process/exec.ts | 3 ++- 8 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 src/infra/openclaw-exec-env.ts diff --git a/src/agents/sandbox-create-args.test.ts b/src/agents/sandbox-create-args.test.ts index 9bc00547143..0d9621ad9e1 100644 --- a/src/agents/sandbox-create-args.test.ts +++ b/src/agents/sandbox-create-args.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { OPENCLAW_CLI_ENV_VALUE } from "../infra/openclaw-exec-env.js"; import { buildSandboxCreateArgs } from "./sandbox/docker.js"; import type { SandboxDockerConfig } from "./sandbox/types.js"; @@ -113,7 +114,14 @@ describe("buildSandboxCreateArgs", () => { "1.5", ]), ); - expect(args).toEqual(expect.arrayContaining(["--env", "LANG=C.UTF-8"])); + expect(args).toEqual( + expect.arrayContaining([ + "--env", + "LANG=C.UTF-8", + "--env", + `OPENCLAW_CLI=${OPENCLAW_CLI_ENV_VALUE}`, + ]), + ); const ulimitValues: string[] = []; for (let i = 0; i < args.length; i += 1) { diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index 2bd9dad12b5..68c95e343ea 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -162,6 +162,7 @@ export function execDockerRaw( } import { formatCliCommand } from "../../cli/command-format.js"; +import { markOpenClawExecEnv } from "../../infra/openclaw-exec-env.js"; import { defaultRuntime } from "../../runtime.js"; import { computeSandboxConfigHash } from "./config-hash.js"; import { DEFAULT_SANDBOX_IMAGE } from "./constants.js"; @@ -365,7 +366,7 @@ export function buildSandboxCreateArgs(params: { if (params.cfg.user) { args.push("--user", params.cfg.user); } - const envSanitization = sanitizeEnvVars(params.cfg.env ?? {}); + const envSanitization = sanitizeEnvVars(markOpenClawExecEnv(params.cfg.env ?? {})); if (envSanitization.blocked.length > 0) { log.warn(`Blocked sensitive environment variables: ${envSanitization.blocked.join(", ")}`); } diff --git a/src/entry.ts b/src/entry.ts index 50b08029d05..14a839f38b9 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -9,6 +9,7 @@ import { shouldSkipRespawnForArgv } from "./cli/respawn-policy.js"; import { normalizeWindowsArgv } from "./cli/windows-argv.js"; import { isTruthyEnvValue, normalizeEnv } from "./infra/env.js"; import { isMainModule } from "./infra/is-main.js"; +import { ensureOpenClawExecMarkerOnProcess } from "./infra/openclaw-exec-env.js"; import { installProcessWarningFilter } from "./infra/warning-filter.js"; import { attachChildProcessBridge } from "./process/child-process-bridge.js"; @@ -41,6 +42,7 @@ if ( // Imported as a dependency — skip all entry-point side effects. } else { process.title = "openclaw"; + ensureOpenClawExecMarkerOnProcess(); installProcessWarningFilter(); normalizeEnv(); if (!isTruthyEnvValue(process.env.NODE_DISABLE_COMPILE_CACHE)) { diff --git a/src/infra/host-env-security.test.ts b/src/infra/host-env-security.test.ts index 116006dbbcf..4e7bcdb9ed9 100644 --- a/src/infra/host-env-security.test.ts +++ b/src/infra/host-env-security.test.ts @@ -10,6 +10,7 @@ import { sanitizeHostExecEnv, sanitizeSystemRunEnvOverrides, } from "./host-env-security.js"; +import { OPENCLAW_CLI_ENV_VALUE } from "./openclaw-exec-env.js"; describe("isDangerousHostEnvVarName", () => { it("matches dangerous keys and prefixes case-insensitively", () => { @@ -40,6 +41,7 @@ describe("sanitizeHostExecEnv", () => { }); expect(env).toEqual({ + OPENCLAW_CLI: OPENCLAW_CLI_ENV_VALUE, PATH: "/usr/bin:/bin", OK: "1", }); @@ -68,6 +70,7 @@ describe("sanitizeHostExecEnv", () => { }); expect(env.PATH).toBe("/usr/bin:/bin"); + expect(env.OPENCLAW_CLI).toBe(OPENCLAW_CLI_ENV_VALUE); expect(env.BASH_ENV).toBeUndefined(); expect(env.GIT_SSH_COMMAND).toBeUndefined(); expect(env.EDITOR).toBeUndefined(); @@ -91,6 +94,7 @@ describe("sanitizeHostExecEnv", () => { }); expect(env.PATH).toBe("/usr/bin:/bin"); + expect(env.OPENCLAW_CLI).toBe(OPENCLAW_CLI_ENV_VALUE); expect(env.OK).toBe("1"); expect(env.SHELLOPTS).toBeUndefined(); expect(env.PS4).toBeUndefined(); @@ -109,6 +113,7 @@ describe("sanitizeHostExecEnv", () => { }); expect(env.GOOD_KEY).toBe("ok"); + expect(env.OPENCLAW_CLI).toBe(OPENCLAW_CLI_ENV_VALUE); expect(env[" BAD KEY"]).toBeUndefined(); expect(env["NOT-PORTABLE"]).toBeUndefined(); }); diff --git a/src/infra/host-env-security.ts b/src/infra/host-env-security.ts index 56b30bd0818..8c5d0989fdd 100644 --- a/src/infra/host-env-security.ts +++ b/src/infra/host-env-security.ts @@ -1,4 +1,5 @@ import HOST_ENV_SECURITY_POLICY_JSON from "./host-env-security-policy.json" with { type: "json" }; +import { markOpenClawExecEnv } from "./openclaw-exec-env.js"; const PORTABLE_ENV_VAR_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/; @@ -101,7 +102,7 @@ export function sanitizeHostExecEnv(params?: { } if (!overrides) { - return merged; + return markOpenClawExecEnv(merged); } for (const [rawKey, value] of Object.entries(overrides)) { @@ -124,7 +125,7 @@ export function sanitizeHostExecEnv(params?: { merged[key] = value; } - return merged; + return markOpenClawExecEnv(merged); } export function sanitizeSystemRunEnvOverrides(params?: { diff --git a/src/infra/openclaw-exec-env.ts b/src/infra/openclaw-exec-env.ts new file mode 100644 index 00000000000..b4e8a876584 --- /dev/null +++ b/src/infra/openclaw-exec-env.ts @@ -0,0 +1,16 @@ +export const OPENCLAW_CLI_ENV_VAR = "OPENCLAW_CLI"; +export const OPENCLAW_CLI_ENV_VALUE = "1"; + +export function markOpenClawExecEnv>(env: T): T { + return { + ...env, + [OPENCLAW_CLI_ENV_VAR]: OPENCLAW_CLI_ENV_VALUE, + }; +} + +export function ensureOpenClawExecMarkerOnProcess( + env: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + env[OPENCLAW_CLI_ENV_VAR] = OPENCLAW_CLI_ENV_VALUE; + return env; +} diff --git a/src/process/exec.test.ts b/src/process/exec.test.ts index 19937d6cb32..88d9cfdd71e 100644 --- a/src/process/exec.test.ts +++ b/src/process/exec.test.ts @@ -3,6 +3,7 @@ import { EventEmitter } from "node:events"; import fs from "node:fs"; import process from "node:process"; import { describe, expect, it, vi } from "vitest"; +import { OPENCLAW_CLI_ENV_VALUE } from "../infra/openclaw-exec-env.js"; import { attachChildProcessBridge } from "./child-process-bridge.js"; import { resolveCommandEnv, runCommandWithTimeout, shouldSpawnWithShell } from "./exec.js"; @@ -31,6 +32,7 @@ describe("runCommandWithTimeout", () => { expect(resolved.OPENCLAW_BASE_ENV).toBe("base"); expect(resolved.OPENCLAW_TEST_ENV).toBe("ok"); expect(resolved.OPENCLAW_TO_REMOVE).toBeUndefined(); + expect(resolved.OPENCLAW_CLI).toBe(OPENCLAW_CLI_ENV_VALUE); }); it("suppresses npm fund prompts for npm argv", async () => { diff --git a/src/process/exec.ts b/src/process/exec.ts index ddc572092d8..3464a083894 100644 --- a/src/process/exec.ts +++ b/src/process/exec.ts @@ -4,6 +4,7 @@ import path from "node:path"; import process from "node:process"; import { promisify } from "node:util"; import { danger, shouldLogVerbose } from "../globals.js"; +import { markOpenClawExecEnv } from "../infra/openclaw-exec-env.js"; import { logDebug, logError } from "../logger.js"; import { resolveCommandStdio } from "./spawn-utils.js"; @@ -213,7 +214,7 @@ export function resolveCommandEnv(params: { resolvedEnv.npm_config_fund = "false"; } } - return resolvedEnv; + return markOpenClawExecEnv(resolvedEnv); } export async function runCommandWithTimeout( From c0cba7fb72ea7490b89ab194041287bea4017f3e Mon Sep 17 00:00:00 2001 From: Julia Barth <72460857+Julbarth@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:34:46 -0700 Subject: [PATCH 05/11] Fix one-shot exit hangs by tearing down cached memory managers (#40389) Merged via squash. Prepared head SHA: 0e600e89cf10f5086ab9d93f445587373a54dcec Co-authored-by: Julbarth <72460857+Julbarth@users.noreply.github.com> Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com> Reviewed-by: @frankekn --- CHANGELOG.md | 1 + src/cli/run-main.exit.test.ts | 6 ++ src/cli/run-main.ts | 111 ++++++++++++--------- src/memory/index.ts | 6 +- src/memory/manager-runtime.ts | 2 +- src/memory/manager.get-concurrency.test.ts | 37 +++++++ src/memory/manager.ts | 16 +++ src/memory/search-manager.test.ts | 107 +++++++++++++------- src/memory/search-manager.ts | 16 +++ 9 files changed, 214 insertions(+), 88 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f705ed77a3..ce8a07061ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ Docs: https://docs.openclaw.ai - Logging/probe observations: suppress structured embedded and model-fallback probe warnings on the console without hiding error or fatal output. (#41338) thanks @altaywtf. - Agents/fallback: treat HTTP 499 responses as transient in both raw-text and structured failover paths so Anthropic-style client-closed overload responses trigger model fallback reliably. (#41468) thanks @zeroasterisk. - Plugins/context-engine model auth: expose `runtime.modelAuth` and plugin-sdk auth helpers so plugins can resolve provider/model API keys through the normal auth pipeline. (#41090) thanks @xinhuagu. +- CLI/memory teardown: close cached memory search/index managers in the one-shot CLI shutdown path so watcher-backed memory caches no longer keep completed CLI runs alive after output finishes. (#40389) thanks @Julbarth. ## 2026.3.8 diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index 86d74f09640..3e56c1ce794 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -6,6 +6,7 @@ const loadDotEnvMock = vi.hoisted(() => vi.fn()); const normalizeEnvMock = vi.hoisted(() => vi.fn()); const ensurePathMock = vi.hoisted(() => vi.fn()); const assertRuntimeMock = vi.hoisted(() => vi.fn()); +const closeAllMemorySearchManagersMock = vi.hoisted(() => vi.fn(async () => {})); vi.mock("./route.js", () => ({ tryRouteCli: tryRouteCliMock, @@ -27,6 +28,10 @@ vi.mock("../infra/runtime-guard.js", () => ({ assertSupportedRuntime: assertRuntimeMock, })); +vi.mock("../memory/search-manager.js", () => ({ + closeAllMemorySearchManagers: closeAllMemorySearchManagersMock, +})); + const { runCli } = await import("./run-main.js"); describe("runCli exit behavior", () => { @@ -43,6 +48,7 @@ describe("runCli exit behavior", () => { await runCli(["node", "openclaw", "status"]); expect(tryRouteCliMock).toHaveBeenCalledWith(["node", "openclaw", "status"]); + expect(closeAllMemorySearchManagersMock).toHaveBeenCalledTimes(1); expect(exitSpy).not.toHaveBeenCalled(); exitSpy.mockRestore(); }); diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index e80ce97b845..c0673ddf2af 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -13,6 +13,15 @@ import { applyCliProfileEnv, parseCliProfileArgs } from "./profile.js"; import { tryRouteCli } from "./route.js"; import { normalizeWindowsArgv } from "./windows-argv.js"; +async function closeCliMemoryManagers(): Promise { + try { + const { closeAllMemorySearchManagers } = await import("../memory/search-manager.js"); + await closeAllMemorySearchManagers(); + } catch { + // Best-effort teardown for short-lived CLI processes. + } +} + export function rewriteUpdateFlagArgv(argv: string[]): string[] { const index = argv.indexOf("--update"); if (index === -1) { @@ -82,59 +91,63 @@ export async function runCli(argv: string[] = process.argv) { // Enforce the minimum supported runtime before doing any work. assertSupportedRuntime(); - if (await tryRouteCli(normalizedArgv)) { - return; - } - - // Capture all console output into structured logs while keeping stdout/stderr behavior. - enableConsoleCapture(); - - const { buildProgram } = await import("./program.js"); - const program = buildProgram(); - - // Global error handlers to prevent silent crashes from unhandled rejections/exceptions. - // These log the error and exit gracefully instead of crashing without trace. - installUnhandledRejectionHandler(); - - process.on("uncaughtException", (error) => { - console.error("[openclaw] Uncaught exception:", formatUncaughtError(error)); - process.exit(1); - }); - - const parseArgv = rewriteUpdateFlagArgv(normalizedArgv); - // Register the primary command (builtin or subcli) so help and command parsing - // are correct even with lazy command registration. - const primary = getPrimaryCommand(parseArgv); - if (primary) { - const { getProgramContext } = await import("./program/program-context.js"); - const ctx = getProgramContext(program); - if (ctx) { - const { registerCoreCliByName } = await import("./program/command-registry.js"); - await registerCoreCliByName(program, ctx, primary, parseArgv); + try { + if (await tryRouteCli(normalizedArgv)) { + return; } - const { registerSubCliByName } = await import("./program/register.subclis.js"); - await registerSubCliByName(program, primary); - } - const hasBuiltinPrimary = - primary !== null && program.commands.some((command) => command.name() === primary); - const shouldSkipPluginRegistration = shouldSkipPluginCommandRegistration({ - argv: parseArgv, - primary, - hasBuiltinPrimary, - }); - if (!shouldSkipPluginRegistration) { - // Register plugin CLI commands before parsing - const { registerPluginCliCommands } = await import("../plugins/cli.js"); - const { loadValidatedConfigForPluginRegistration } = - await import("./program/register.subclis.js"); - const config = await loadValidatedConfigForPluginRegistration(); - if (config) { - registerPluginCliCommands(program, config); + // Capture all console output into structured logs while keeping stdout/stderr behavior. + enableConsoleCapture(); + + const { buildProgram } = await import("./program.js"); + const program = buildProgram(); + + // Global error handlers to prevent silent crashes from unhandled rejections/exceptions. + // These log the error and exit gracefully instead of crashing without trace. + installUnhandledRejectionHandler(); + + process.on("uncaughtException", (error) => { + console.error("[openclaw] Uncaught exception:", formatUncaughtError(error)); + process.exit(1); + }); + + const parseArgv = rewriteUpdateFlagArgv(normalizedArgv); + // Register the primary command (builtin or subcli) so help and command parsing + // are correct even with lazy command registration. + const primary = getPrimaryCommand(parseArgv); + if (primary) { + const { getProgramContext } = await import("./program/program-context.js"); + const ctx = getProgramContext(program); + if (ctx) { + const { registerCoreCliByName } = await import("./program/command-registry.js"); + await registerCoreCliByName(program, ctx, primary, parseArgv); + } + const { registerSubCliByName } = await import("./program/register.subclis.js"); + await registerSubCliByName(program, primary); } - } - await program.parseAsync(parseArgv); + const hasBuiltinPrimary = + primary !== null && program.commands.some((command) => command.name() === primary); + const shouldSkipPluginRegistration = shouldSkipPluginCommandRegistration({ + argv: parseArgv, + primary, + hasBuiltinPrimary, + }); + if (!shouldSkipPluginRegistration) { + // Register plugin CLI commands before parsing + const { registerPluginCliCommands } = await import("../plugins/cli.js"); + const { loadValidatedConfigForPluginRegistration } = + await import("./program/register.subclis.js"); + const config = await loadValidatedConfigForPluginRegistration(); + if (config) { + registerPluginCliCommands(program, config); + } + } + + await program.parseAsync(parseArgv); + } finally { + await closeCliMemoryManagers(); + } } export function isCliMainModule(): boolean { diff --git a/src/memory/index.ts b/src/memory/index.ts index 4d2df05a399..86ca52e1d27 100644 --- a/src/memory/index.ts +++ b/src/memory/index.ts @@ -4,4 +4,8 @@ export type { MemorySearchManager, MemorySearchResult, } from "./types.js"; -export { getMemorySearchManager, type MemorySearchManagerResult } from "./search-manager.js"; +export { + closeAllMemorySearchManagers, + getMemorySearchManager, + type MemorySearchManagerResult, +} from "./search-manager.js"; diff --git a/src/memory/manager-runtime.ts b/src/memory/manager-runtime.ts index b46b3708a6e..3e910b5676a 100644 --- a/src/memory/manager-runtime.ts +++ b/src/memory/manager-runtime.ts @@ -1 +1 @@ -export { MemoryIndexManager } from "./manager.js"; +export { closeAllMemoryIndexManagers, MemoryIndexManager } from "./manager.js"; diff --git a/src/memory/manager.get-concurrency.test.ts b/src/memory/manager.get-concurrency.test.ts index e7d040217a8..67b10768fc3 100644 --- a/src/memory/manager.get-concurrency.test.ts +++ b/src/memory/manager.get-concurrency.test.ts @@ -4,6 +4,10 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { getMemorySearchManager, type MemoryIndexManager } from "./index.js"; +import { + closeAllMemoryIndexManagers, + MemoryIndexManager as RawMemoryIndexManager, +} from "./manager.js"; import "./test-runtime-mocks.js"; const hoisted = vi.hoisted(() => ({ @@ -78,4 +82,37 @@ describe("memory manager cache hydration", () => { await managers[0].close(); }); + + it("drains in-flight manager creation during global teardown", async () => { + const indexPath = path.join(workspaceDir, "index.sqlite"); + const cfg = { + agents: { + defaults: { + workspace: workspaceDir, + memorySearch: { + provider: "openai", + model: "mock-embed", + store: { path: indexPath, vector: { enabled: false } }, + sync: { watch: false, onSessionStart: false, onSearch: false }, + }, + }, + list: [{ id: "main", default: true }], + }, + } as OpenClawConfig; + + hoisted.providerDelayMs = 100; + + const pendingResult = RawMemoryIndexManager.get({ cfg, agentId: "main" }); + await closeAllMemoryIndexManagers(); + const firstManager = await pendingResult; + + const secondManager = await RawMemoryIndexManager.get({ cfg, agentId: "main" }); + + expect(firstManager).toBeTruthy(); + expect(secondManager).toBeTruthy(); + expect(Object.is(secondManager, firstManager)).toBe(false); + expect(hoisted.providerCreateCalls).toBe(2); + + await secondManager?.close?.(); + }); }); diff --git a/src/memory/manager.ts b/src/memory/manager.ts index 1d2fb49e88b..9b1ff74e54c 100644 --- a/src/memory/manager.ts +++ b/src/memory/manager.ts @@ -42,6 +42,22 @@ const log = createSubsystemLogger("memory"); const INDEX_CACHE = new Map(); const INDEX_CACHE_PENDING = new Map>(); +export async function closeAllMemoryIndexManagers(): Promise { + const pending = Array.from(INDEX_CACHE_PENDING.values()); + if (pending.length > 0) { + await Promise.allSettled(pending); + } + const managers = Array.from(INDEX_CACHE.values()); + INDEX_CACHE.clear(); + for (const manager of managers) { + try { + await manager.close(); + } catch (err) { + log.warn(`failed to close memory index manager: ${String(err)}`); + } + } +} + export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements MemorySearchManager { private readonly cacheKey: string; protected readonly cfg: OpenClawConfig; diff --git a/src/memory/search-manager.test.ts b/src/memory/search-manager.test.ts index d853f5af1fa..1f705aeddcf 100644 --- a/src/memory/search-manager.test.ts +++ b/src/memory/search-manager.test.ts @@ -29,53 +29,53 @@ function createManagerStatus(params: { }; } -const qmdManagerStatus = createManagerStatus({ - backend: "qmd", - provider: "qmd", - model: "qmd", - requestedProvider: "qmd", - withMemorySourceCounts: true, -}); - -const fallbackManagerStatus = createManagerStatus({ - backend: "builtin", - provider: "openai", - model: "text-embedding-3-small", - requestedProvider: "openai", -}); - -const mockPrimary = { +const mockPrimary = vi.hoisted(() => ({ search: vi.fn(async () => []), readFile: vi.fn(async () => ({ text: "", path: "MEMORY.md" })), - status: vi.fn(() => qmdManagerStatus), + status: vi.fn(() => + createManagerStatus({ + backend: "qmd", + provider: "qmd", + model: "qmd", + requestedProvider: "qmd", + withMemorySourceCounts: true, + }), + ), sync: vi.fn(async () => {}), probeEmbeddingAvailability: vi.fn(async () => ({ ok: true })), probeVectorAvailability: vi.fn(async () => true), close: vi.fn(async () => {}), -}; +})); -const fallbackSearch = vi.fn(async () => [ - { - path: "MEMORY.md", - startLine: 1, - endLine: 1, - score: 1, - snippet: "fallback", - source: "memory" as const, - }, -]); - -const fallbackManager = { - search: fallbackSearch, +const fallbackManager = vi.hoisted(() => ({ + search: vi.fn(async () => [ + { + path: "MEMORY.md", + startLine: 1, + endLine: 1, + score: 1, + snippet: "fallback", + source: "memory" as const, + }, + ]), readFile: vi.fn(async () => ({ text: "", path: "MEMORY.md" })), - status: vi.fn(() => fallbackManagerStatus), + status: vi.fn(() => + createManagerStatus({ + backend: "builtin", + provider: "openai", + model: "text-embedding-3-small", + requestedProvider: "openai", + }), + ), sync: vi.fn(async () => {}), probeEmbeddingAvailability: vi.fn(async () => ({ ok: true })), probeVectorAvailability: vi.fn(async () => true), close: vi.fn(async () => {}), -}; +})); -const mockMemoryIndexGet = vi.fn(async () => fallbackManager); +const fallbackSearch = fallbackManager.search; +const mockMemoryIndexGet = vi.hoisted(() => vi.fn(async () => fallbackManager)); +const mockCloseAllMemoryIndexManagers = vi.hoisted(() => vi.fn(async () => {})); vi.mock("./qmd-manager.js", () => ({ QmdMemoryManager: { @@ -83,14 +83,15 @@ vi.mock("./qmd-manager.js", () => ({ }, })); -vi.mock("./manager.js", () => ({ +vi.mock("./manager-runtime.js", () => ({ MemoryIndexManager: { get: mockMemoryIndexGet, }, + closeAllMemoryIndexManagers: mockCloseAllMemoryIndexManagers, })); import { QmdMemoryManager } from "./qmd-manager.js"; -import { getMemorySearchManager } from "./search-manager.js"; +import { closeAllMemorySearchManagers, getMemorySearchManager } from "./search-manager.js"; // eslint-disable-next-line @typescript-eslint/unbound-method -- mocked static function const createQmdManagerMock = vi.mocked(QmdMemoryManager.create); @@ -119,7 +120,8 @@ async function createFailedQmdSearchHarness(params: { agentId: string; errorMess return { cfg, manager: requireManager(first), firstResult: first }; } -beforeEach(() => { +beforeEach(async () => { + await closeAllMemorySearchManagers(); mockPrimary.search.mockClear(); mockPrimary.readFile.mockClear(); mockPrimary.status.mockClear(); @@ -134,6 +136,7 @@ beforeEach(() => { fallbackManager.probeEmbeddingAvailability.mockClear(); fallbackManager.probeVectorAvailability.mockClear(); fallbackManager.close.mockClear(); + mockCloseAllMemoryIndexManagers.mockClear(); mockMemoryIndexGet.mockClear(); mockMemoryIndexGet.mockResolvedValue(fallbackManager); createQmdManagerMock.mockClear(); @@ -243,4 +246,34 @@ describe("getMemorySearchManager caching", () => { await expect(firstManager.search("hello")).rejects.toThrow("qmd query failed"); }); + + it("closes cached managers on global teardown", async () => { + const cfg = createQmdCfg("teardown-agent"); + const first = await getMemorySearchManager({ cfg, agentId: "teardown-agent" }); + const firstManager = requireManager(first); + + await closeAllMemorySearchManagers(); + + expect(mockPrimary.close).toHaveBeenCalledTimes(1); + expect(mockCloseAllMemoryIndexManagers).toHaveBeenCalledTimes(1); + + const second = await getMemorySearchManager({ cfg, agentId: "teardown-agent" }); + expect(second.manager).toBeTruthy(); + expect(second.manager).not.toBe(firstManager); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(createQmdManagerMock).toHaveBeenCalledTimes(2); + }); + + it("closes builtin index managers on teardown after runtime is loaded", async () => { + const retryAgentId = "teardown-with-fallback"; + const { manager } = await createFailedQmdSearchHarness({ + agentId: retryAgentId, + errorMessage: "qmd query failed", + }); + await manager.search("hello"); + + await closeAllMemorySearchManagers(); + + expect(mockCloseAllMemoryIndexManagers).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/memory/search-manager.ts b/src/memory/search-manager.ts index f4e351fdc1a..ea581b5d6da 100644 --- a/src/memory/search-manager.ts +++ b/src/memory/search-manager.ts @@ -85,6 +85,22 @@ export async function getMemorySearchManager(params: { } } +export async function closeAllMemorySearchManagers(): Promise { + const managers = Array.from(QMD_MANAGER_CACHE.values()); + QMD_MANAGER_CACHE.clear(); + for (const manager of managers) { + try { + await manager.close?.(); + } catch (err) { + log.warn(`failed to close qmd memory manager: ${String(err)}`); + } + } + if (managerRuntimePromise !== null) { + const { closeAllMemoryIndexManagers } = await loadManagerRuntime(); + await closeAllMemoryIndexManagers(); + } +} + class FallbackMemoryManager implements MemorySearchManager { private fallback: MemorySearchManager | null = null; private primaryFailed = false; From 5a659b0b61dbfa1645fdfa28bf9bffee03a8c9bc Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 9 Mar 2026 17:49:06 -0500 Subject: [PATCH 06/11] feat(ui): add chat infrastructure modules (slice 1 of dashboard-v2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New self-contained chat modules extracted from dashboard-v2-structure: - chat/slash-commands.ts: slash command definitions and completions - chat/slash-command-executor.ts: execute slash commands via gateway RPC - chat/slash-command-executor.node.test.ts: test coverage - chat/speech.ts: speech-to-text (STT) support - chat/input-history.ts: per-session input history navigation - chat/pinned-messages.ts: pinned message management - chat/deleted-messages.ts: deleted message tracking - chat/export.ts: shared exportChatMarkdown helper - chat-export.ts: re-export shim for backwards compat Gateway fix: - Restore usage/cost stripping in chat.history sanitization - Add test coverage for sanitization behavior These modules are additive and tree-shaken — no existing code imports them yet. They will be wired in subsequent slices. --- src/gateway/server-methods/chat.ts | 93 ++++- .../server.chat.gateway-server-chat-b.test.ts | 31 ++ ui/src/ui/chat-export.ts | 1 + ui/src/ui/chat/deleted-messages.ts | 49 +++ ui/src/ui/chat/export.ts | 24 ++ ui/src/ui/chat/input-history.ts | 49 +++ ui/src/ui/chat/pinned-messages.ts | 61 +++ .../chat/slash-command-executor.node.test.ts | 83 ++++ ui/src/ui/chat/slash-command-executor.ts | 370 ++++++++++++++++++ ui/src/ui/chat/slash-commands.ts | 217 ++++++++++ ui/src/ui/chat/speech.ts | 225 +++++++++++ 11 files changed, 1196 insertions(+), 7 deletions(-) create mode 100644 ui/src/ui/chat-export.ts create mode 100644 ui/src/ui/chat/deleted-messages.ts create mode 100644 ui/src/ui/chat/export.ts create mode 100644 ui/src/ui/chat/input-history.ts create mode 100644 ui/src/ui/chat/pinned-messages.ts create mode 100644 ui/src/ui/chat/slash-command-executor.node.test.ts create mode 100644 ui/src/ui/chat/slash-command-executor.ts create mode 100644 ui/src/ui/chat/slash-commands.ts create mode 100644 ui/src/ui/chat/speech.ts diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 71669080382..291e323b671 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -314,6 +314,60 @@ function sanitizeChatHistoryContentBlock(block: unknown): { block: unknown; chan return { block: changed ? entry : block, changed }; } +/** + * Validate that a value is a finite number, returning undefined otherwise. + */ +function toFiniteNumber(x: unknown): number | undefined { + return typeof x === "number" && Number.isFinite(x) ? x : undefined; +} + +/** + * Sanitize usage metadata to ensure only finite numeric fields are included. + * Prevents UI crashes from malformed transcript JSON. + */ +function sanitizeUsage(raw: unknown): Record | undefined { + if (!raw || typeof raw !== "object") { + return undefined; + } + const u = raw as Record; + const out: Record = {}; + + // Whitelist known usage fields and validate they're finite numbers + const knownFields = [ + "input", + "output", + "totalTokens", + "inputTokens", + "outputTokens", + "cacheRead", + "cacheWrite", + "cache_read_input_tokens", + "cache_creation_input_tokens", + ]; + + for (const k of knownFields) { + const n = toFiniteNumber(u[k]); + if (n !== undefined) { + out[k] = n; + } + } + + return Object.keys(out).length > 0 ? out : undefined; +} + +/** + * Sanitize cost metadata to ensure only finite numeric fields are included. + * Prevents UI crashes from calling .toFixed() on non-numbers. + */ +function sanitizeCost(raw: unknown): { total?: number } | undefined { + if (!raw || typeof raw !== "object") { + return undefined; + } + const c = raw as Record; + const total = toFiniteNumber(c.total); + return total !== undefined ? { total } : undefined; +} + function sanitizeChatHistoryMessage(message: unknown): { message: unknown; changed: boolean } { if (!message || typeof message !== "object") { return { message, changed: false }; @@ -325,13 +379,38 @@ function sanitizeChatHistoryMessage(message: unknown): { message: unknown; chang delete entry.details; changed = true; } - if ("usage" in entry) { - delete entry.usage; - changed = true; - } - if ("cost" in entry) { - delete entry.cost; - changed = true; + + // Keep usage/cost so the chat UI can render per-message token and cost badges. + // Only retain usage/cost on assistant messages and validate numeric fields to prevent UI crashes. + if (entry.role !== "assistant") { + if ("usage" in entry) { + delete entry.usage; + changed = true; + } + if ("cost" in entry) { + delete entry.cost; + changed = true; + } + } else { + // Validate and sanitize usage/cost for assistant messages + if ("usage" in entry) { + const sanitized = sanitizeUsage(entry.usage); + if (sanitized) { + entry.usage = sanitized; + } else { + delete entry.usage; + } + changed = true; + } + if ("cost" in entry) { + const sanitized = sanitizeCost(entry.cost); + if (sanitized) { + entry.cost = sanitized; + } else { + delete entry.cost; + } + changed = true; + } } if (typeof entry.content === "string") { 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/chat-export.ts b/ui/src/ui/chat-export.ts new file mode 100644 index 00000000000..ed5bbf931f8 --- /dev/null +++ b/ui/src/ui/chat-export.ts @@ -0,0 +1 @@ +export { exportChatMarkdown } from "./chat/export.ts"; diff --git a/ui/src/ui/chat/deleted-messages.ts b/ui/src/ui/chat/deleted-messages.ts new file mode 100644 index 00000000000..fd3916d78c7 --- /dev/null +++ b/ui/src/ui/chat/deleted-messages.ts @@ -0,0 +1,49 @@ +const PREFIX = "openclaw:deleted:"; + +export class DeletedMessages { + private key: string; + private _keys = new Set(); + + constructor(sessionKey: string) { + this.key = PREFIX + sessionKey; + this.load(); + } + + has(key: string): boolean { + return this._keys.has(key); + } + + delete(key: string): void { + this._keys.add(key); + this.save(); + } + + restore(key: string): void { + this._keys.delete(key); + this.save(); + } + + clear(): void { + this._keys.clear(); + this.save(); + } + + private load(): void { + try { + const raw = localStorage.getItem(this.key); + if (!raw) { + return; + } + const arr = JSON.parse(raw); + if (Array.isArray(arr)) { + this._keys = new Set(arr.filter((s) => typeof s === "string")); + } + } catch { + // ignore + } + } + + private save(): void { + localStorage.setItem(this.key, JSON.stringify([...this._keys])); + } +} diff --git a/ui/src/ui/chat/export.ts b/ui/src/ui/chat/export.ts new file mode 100644 index 00000000000..31e15e592e2 --- /dev/null +++ b/ui/src/ui/chat/export.ts @@ -0,0 +1,24 @@ +/** + * Export chat history as markdown file. + */ +export function exportChatMarkdown(messages: unknown[], assistantName: string): void { + const history = Array.isArray(messages) ? messages : []; + if (history.length === 0) { + return; + } + const lines: string[] = [`# Chat with ${assistantName}`, ""]; + for (const msg of history) { + const m = msg as Record; + const role = m.role === "user" ? "You" : m.role === "assistant" ? assistantName : "Tool"; + const content = typeof m.content === "string" ? m.content : ""; + const ts = typeof m.timestamp === "number" ? new Date(m.timestamp).toISOString() : ""; + lines.push(`## ${role}${ts ? ` (${ts})` : ""}`, "", content, ""); + } + const blob = new Blob([lines.join("\n")], { type: "text/markdown" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `chat-${assistantName}-${Date.now()}.md`; + link.click(); + URL.revokeObjectURL(url); +} diff --git a/ui/src/ui/chat/input-history.ts b/ui/src/ui/chat/input-history.ts new file mode 100644 index 00000000000..34d8806d072 --- /dev/null +++ b/ui/src/ui/chat/input-history.ts @@ -0,0 +1,49 @@ +const MAX = 50; + +export class InputHistory { + private items: string[] = []; + private cursor = -1; + + push(text: string): void { + const trimmed = text.trim(); + if (!trimmed) { + return; + } + if (this.items[this.items.length - 1] === trimmed) { + return; + } + this.items.push(trimmed); + if (this.items.length > MAX) { + this.items.shift(); + } + this.cursor = -1; + } + + up(): string | null { + if (this.items.length === 0) { + return null; + } + if (this.cursor < 0) { + this.cursor = this.items.length - 1; + } else if (this.cursor > 0) { + this.cursor--; + } + return this.items[this.cursor] ?? null; + } + + down(): string | null { + if (this.cursor < 0) { + return null; + } + this.cursor++; + if (this.cursor >= this.items.length) { + this.cursor = -1; + return null; + } + return this.items[this.cursor] ?? null; + } + + reset(): void { + this.cursor = -1; + } +} diff --git a/ui/src/ui/chat/pinned-messages.ts b/ui/src/ui/chat/pinned-messages.ts new file mode 100644 index 00000000000..4914b0db32a --- /dev/null +++ b/ui/src/ui/chat/pinned-messages.ts @@ -0,0 +1,61 @@ +const PREFIX = "openclaw:pinned:"; + +export class PinnedMessages { + private key: string; + private _indices = new Set(); + + constructor(sessionKey: string) { + this.key = PREFIX + sessionKey; + this.load(); + } + + get indices(): Set { + return this._indices; + } + + has(index: number): boolean { + return this._indices.has(index); + } + + pin(index: number): void { + this._indices.add(index); + this.save(); + } + + unpin(index: number): void { + this._indices.delete(index); + this.save(); + } + + toggle(index: number): void { + if (this._indices.has(index)) { + this.unpin(index); + } else { + this.pin(index); + } + } + + clear(): void { + this._indices.clear(); + this.save(); + } + + private load(): void { + try { + const raw = localStorage.getItem(this.key); + if (!raw) { + return; + } + const arr = JSON.parse(raw); + if (Array.isArray(arr)) { + this._indices = new Set(arr.filter((n) => typeof n === "number")); + } + } catch { + // ignore + } + } + + private save(): void { + localStorage.setItem(this.key, JSON.stringify([...this._indices])); + } +} diff --git a/ui/src/ui/chat/slash-command-executor.node.test.ts b/ui/src/ui/chat/slash-command-executor.node.test.ts new file mode 100644 index 00000000000..706bfed0c3c --- /dev/null +++ b/ui/src/ui/chat/slash-command-executor.node.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient } from "../gateway.ts"; +import type { GatewaySessionRow } from "../types.ts"; +import { executeSlashCommand } from "./slash-command-executor.ts"; + +function row(key: string): GatewaySessionRow { + return { + key, + kind: "direct", + updatedAt: null, + }; +} + +describe("executeSlashCommand /kill", () => { + it("aborts every sub-agent session for /kill all", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("main"), + row("agent:main:subagent:one"), + row("agent:main:subagent:parent:subagent:child"), + row("agent:other:main"), + ], + }; + } + if (method === "chat.abort") { + return { ok: true, aborted: true }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "kill", + "all", + ); + + expect(result.content).toBe("Aborted 2 sub-agent sessions."); + expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); + expect(request).toHaveBeenNthCalledWith(2, "chat.abort", { + sessionKey: "agent:main:subagent:one", + }); + expect(request).toHaveBeenNthCalledWith(3, "chat.abort", { + sessionKey: "agent:main:subagent:parent:subagent:child", + }); + }); + + it("aborts matching sub-agent sessions for /kill ", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("agent:main:subagent:one"), + row("agent:main:subagent:two"), + row("agent:other:subagent:three"), + ], + }; + } + if (method === "chat.abort") { + return { ok: true, aborted: true }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "kill", + "main", + ); + + expect(result.content).toBe("Aborted 2 matching sub-agent sessions for `main`."); + expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); + expect(request).toHaveBeenNthCalledWith(2, "chat.abort", { + sessionKey: "agent:main:subagent:one", + }); + expect(request).toHaveBeenNthCalledWith(3, "chat.abort", { + sessionKey: "agent:main:subagent:two", + }); + }); +}); diff --git a/ui/src/ui/chat/slash-command-executor.ts b/ui/src/ui/chat/slash-command-executor.ts new file mode 100644 index 00000000000..3392095c7c1 --- /dev/null +++ b/ui/src/ui/chat/slash-command-executor.ts @@ -0,0 +1,370 @@ +/** + * Client-side execution engine for slash commands. + * Calls gateway RPC methods and returns formatted results. + */ + +import { isSubagentSessionKey, parseAgentSessionKey } from "../../../../src/routing/session-key.js"; +import type { GatewayBrowserClient } from "../gateway.ts"; +import type { + AgentsListResult, + GatewaySessionRow, + HealthSummary, + ModelCatalogEntry, + SessionsListResult, +} from "../types.ts"; +import { SLASH_COMMANDS } from "./slash-commands.ts"; + +export type SlashCommandResult = { + /** Markdown-formatted result to display in chat. */ + content: string; + /** Side-effect action the caller should perform after displaying the result. */ + action?: + | "refresh" + | "export" + | "new-session" + | "reset" + | "stop" + | "clear" + | "toggle-focus" + | "navigate-usage"; +}; + +export async function executeSlashCommand( + client: GatewayBrowserClient, + sessionKey: string, + commandName: string, + args: string, +): Promise { + switch (commandName) { + case "help": + return executeHelp(); + case "status": + return await executeStatus(client); + case "new": + return { content: "Starting new session...", action: "new-session" }; + case "reset": + return { content: "Resetting session...", action: "reset" }; + case "stop": + return { content: "Stopping current run...", action: "stop" }; + case "clear": + return { content: "Chat history cleared.", action: "clear" }; + case "focus": + return { content: "Toggled focus mode.", action: "toggle-focus" }; + case "compact": + return await executeCompact(client, sessionKey); + case "model": + return await executeModel(client, sessionKey, args); + case "think": + return await executeThink(client, sessionKey, args); + case "verbose": + return await executeVerbose(client, sessionKey, args); + case "export": + return { content: "Exporting session...", action: "export" }; + case "usage": + return await executeUsage(client, sessionKey); + case "agents": + return await executeAgents(client); + case "kill": + return await executeKill(client, sessionKey, args); + default: + return { content: `Unknown command: \`/${commandName}\`` }; + } +} + +// ── Command Implementations ── + +function executeHelp(): SlashCommandResult { + const lines = ["**Available Commands**\n"]; + let currentCategory = ""; + + for (const cmd of SLASH_COMMANDS) { + const cat = cmd.category ?? "session"; + if (cat !== currentCategory) { + currentCategory = cat; + lines.push(`**${cat.charAt(0).toUpperCase() + cat.slice(1)}**`); + } + const argStr = cmd.args ? ` ${cmd.args}` : ""; + const local = cmd.executeLocal ? "" : " *(agent)*"; + lines.push(`\`/${cmd.name}${argStr}\` — ${cmd.description}${local}`); + } + + lines.push("\nType `/` to open the command menu."); + return { content: lines.join("\n") }; +} + +async function executeStatus(client: GatewayBrowserClient): Promise { + try { + const health = await client.request("health", {}); + const status = health.ok ? "Healthy" : "Degraded"; + const agentCount = health.agents?.length ?? 0; + const sessionCount = health.sessions?.count ?? 0; + const lines = [ + `**System Status:** ${status}`, + `**Agents:** ${agentCount}`, + `**Sessions:** ${sessionCount}`, + `**Default Agent:** ${health.defaultAgentId || "none"}`, + ]; + if (health.durationMs) { + lines.push(`**Response:** ${health.durationMs}ms`); + } + return { content: lines.join("\n") }; + } catch (err) { + return { content: `Failed to fetch status: ${String(err)}` }; + } +} + +async function executeCompact( + client: GatewayBrowserClient, + sessionKey: string, +): Promise { + try { + await client.request("sessions.compact", { key: sessionKey }); + return { content: "Context compacted successfully.", action: "refresh" }; + } catch (err) { + return { content: `Compaction failed: ${String(err)}` }; + } +} + +async function executeModel( + client: GatewayBrowserClient, + sessionKey: string, + args: string, +): Promise { + if (!args) { + try { + const sessions = await client.request("sessions.list", {}); + const session = sessions?.sessions?.find((s: GatewaySessionRow) => s.key === sessionKey); + const model = session?.model || sessions?.defaults?.model || "default"; + const models = await client.request<{ models: ModelCatalogEntry[] }>("models.list", {}); + const available = models?.models?.map((m: ModelCatalogEntry) => m.id) ?? []; + const lines = [`**Current model:** \`${model}\``]; + if (available.length > 0) { + lines.push( + `**Available:** ${available + .slice(0, 10) + .map((m: string) => `\`${m}\``) + .join(", ")}${available.length > 10 ? ` +${available.length - 10} more` : ""}`, + ); + } + return { content: lines.join("\n") }; + } catch (err) { + return { content: `Failed to get model info: ${String(err)}` }; + } + } + + try { + await client.request("sessions.patch", { key: sessionKey, model: args.trim() }); + return { content: `Model set to \`${args.trim()}\`.`, action: "refresh" }; + } catch (err) { + return { content: `Failed to set model: ${String(err)}` }; + } +} + +async function executeThink( + client: GatewayBrowserClient, + sessionKey: string, + args: string, +): Promise { + const valid = ["off", "low", "medium", "high"]; + const level = args.trim().toLowerCase(); + + if (!level) { + return { + content: `Usage: \`/think <${valid.join("|")}>\``, + }; + } + if (!valid.includes(level)) { + return { + content: `Invalid thinking level \`${level}\`. Choose: ${valid.map((v) => `\`${v}\``).join(", ")}`, + }; + } + + try { + await client.request("sessions.patch", { key: sessionKey, thinkingLevel: level }); + return { + content: `Thinking level set to **${level}**.`, + action: "refresh", + }; + } catch (err) { + return { content: `Failed to set thinking level: ${String(err)}` }; + } +} + +async function executeVerbose( + client: GatewayBrowserClient, + sessionKey: string, + args: string, +): Promise { + const valid = ["on", "off", "full"]; + const level = args.trim().toLowerCase(); + + if (!level) { + return { + content: `Usage: \`/verbose <${valid.join("|")}>\``, + }; + } + if (!valid.includes(level)) { + return { + content: `Invalid verbose level \`${level}\`. Choose: ${valid.map((v) => `\`${v}\``).join(", ")}`, + }; + } + + try { + await client.request("sessions.patch", { key: sessionKey, verboseLevel: level }); + return { + content: `Verbose mode set to **${level}**.`, + action: "refresh", + }; + } catch (err) { + return { content: `Failed to set verbose mode: ${String(err)}` }; + } +} + +async function executeUsage( + client: GatewayBrowserClient, + sessionKey: string, +): Promise { + try { + const sessions = await client.request("sessions.list", {}); + const session = sessions?.sessions?.find((s: GatewaySessionRow) => s.key === sessionKey); + if (!session) { + return { content: "No active session." }; + } + const input = session.inputTokens ?? 0; + const output = session.outputTokens ?? 0; + const total = session.totalTokens ?? input + output; + const ctx = session.contextTokens ?? 0; + const pct = ctx > 0 ? Math.round((input / ctx) * 100) : null; + + const lines = [ + "**Session Usage**", + `Input: **${fmtTokens(input)}** tokens`, + `Output: **${fmtTokens(output)}** tokens`, + `Total: **${fmtTokens(total)}** tokens`, + ]; + if (pct !== null) { + lines.push(`Context: **${pct}%** of ${fmtTokens(ctx)}`); + } + if (session.model) { + lines.push(`Model: \`${session.model}\``); + } + return { content: lines.join("\n") }; + } catch (err) { + return { content: `Failed to get usage: ${String(err)}` }; + } +} + +async function executeAgents(client: GatewayBrowserClient): Promise { + try { + const result = await client.request("agents.list", {}); + const agents = result?.agents ?? []; + if (agents.length === 0) { + return { content: "No agents configured." }; + } + const lines = [`**Agents** (${agents.length})\n`]; + for (const agent of agents) { + const isDefault = agent.id === result?.defaultId; + const name = agent.identity?.name || agent.name || agent.id; + const marker = isDefault ? " *(default)*" : ""; + lines.push(`- \`${agent.id}\` — ${name}${marker}`); + } + return { content: lines.join("\n") }; + } catch (err) { + return { content: `Failed to list agents: ${String(err)}` }; + } +} + +async function executeKill( + client: GatewayBrowserClient, + sessionKey: string, + args: string, +): Promise { + const target = args.trim(); + if (!target) { + return { content: "Usage: `/kill `" }; + } + try { + const sessions = await client.request("sessions.list", {}); + const matched = resolveKillTargets(sessions?.sessions ?? [], sessionKey, target); + if (matched.length === 0) { + return { + content: + target.toLowerCase() === "all" + ? "No active sub-agent sessions found." + : `No matching sub-agent sessions found for \`${target}\`.`, + }; + } + + const results = await Promise.allSettled( + matched.map((key) => client.request("chat.abort", { sessionKey: key })), + ); + const successCount = results.filter((entry) => entry.status === "fulfilled").length; + if (successCount === 0) { + const firstFailure = results.find((entry) => entry.status === "rejected"); + throw firstFailure?.reason ?? new Error("abort failed"); + } + + if (target.toLowerCase() === "all") { + return { + content: + successCount === matched.length + ? `Aborted ${successCount} sub-agent session${successCount === 1 ? "" : "s"}.` + : `Aborted ${successCount} of ${matched.length} sub-agent sessions.`, + }; + } + + return { + content: + successCount === matched.length + ? `Aborted ${successCount} matching sub-agent session${successCount === 1 ? "" : "s"} for \`${target}\`.` + : `Aborted ${successCount} of ${matched.length} matching sub-agent sessions for \`${target}\`.`, + }; + } catch (err) { + return { content: `Failed to abort: ${String(err)}` }; + } +} + +function resolveKillTargets( + sessions: GatewaySessionRow[], + currentSessionKey: string, + target: string, +): string[] { + const normalizedTarget = target.trim().toLowerCase(); + if (!normalizedTarget) { + return []; + } + + const keys = new Set(); + const currentParsed = parseAgentSessionKey(currentSessionKey); + for (const session of sessions) { + const key = session?.key?.trim(); + if (!key || !isSubagentSessionKey(key)) { + continue; + } + const normalizedKey = key.toLowerCase(); + const parsed = parseAgentSessionKey(normalizedKey); + const isMatch = + normalizedTarget === "all" || + normalizedKey === normalizedTarget || + (parsed?.agentId ?? "") === normalizedTarget || + normalizedKey.endsWith(`:subagent:${normalizedTarget}`) || + normalizedKey === `subagent:${normalizedTarget}` || + (currentParsed?.agentId != null && + parsed?.agentId === currentParsed.agentId && + normalizedKey.endsWith(`:subagent:${normalizedTarget}`)); + if (isMatch) { + keys.add(key); + } + } + return [...keys]; +} + +function fmtTokens(n: number): string { + if (n >= 1_000_000) { + return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; + } + if (n >= 1_000) { + return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`; + } + return String(n); +} diff --git a/ui/src/ui/chat/slash-commands.ts b/ui/src/ui/chat/slash-commands.ts new file mode 100644 index 00000000000..d26a82e1544 --- /dev/null +++ b/ui/src/ui/chat/slash-commands.ts @@ -0,0 +1,217 @@ +import type { IconName } from "../icons.ts"; + +export type SlashCommandCategory = "session" | "model" | "agents" | "tools"; + +export type SlashCommandDef = { + name: string; + description: string; + args?: string; + icon?: IconName; + category?: SlashCommandCategory; + /** When true, the command is executed client-side via RPC instead of sent to the agent. */ + executeLocal?: boolean; + /** Fixed argument choices for inline hints. */ + argOptions?: string[]; + /** Keyboard shortcut hint shown in the menu (display only). */ + shortcut?: string; +}; + +export const SLASH_COMMANDS: SlashCommandDef[] = [ + // ── Session ── + { + name: "new", + description: "Start a new session", + icon: "plus", + category: "session", + executeLocal: true, + }, + { + name: "reset", + description: "Reset current session", + icon: "refresh", + category: "session", + executeLocal: true, + }, + { + name: "compact", + description: "Compact session context", + icon: "loader", + category: "session", + executeLocal: true, + }, + { + name: "stop", + description: "Stop current run", + icon: "stop", + category: "session", + executeLocal: true, + }, + { + name: "clear", + description: "Clear chat history", + icon: "trash", + category: "session", + executeLocal: true, + }, + { + name: "focus", + description: "Toggle focus mode", + icon: "eye", + category: "session", + executeLocal: true, + }, + + // ── Model ── + { + name: "model", + description: "Show or set model", + args: "", + icon: "brain", + category: "model", + executeLocal: true, + }, + { + name: "think", + description: "Set thinking level", + args: "", + icon: "brain", + category: "model", + executeLocal: true, + argOptions: ["off", "low", "medium", "high"], + }, + { + name: "verbose", + description: "Toggle verbose mode", + args: "", + icon: "terminal", + category: "model", + executeLocal: true, + argOptions: ["on", "off", "full"], + }, + + // ── Tools ── + { + name: "help", + description: "Show available commands", + icon: "book", + category: "tools", + executeLocal: true, + }, + { + name: "status", + description: "Show system status", + icon: "barChart", + category: "tools", + executeLocal: true, + }, + { + name: "export", + description: "Export session to Markdown", + icon: "download", + category: "tools", + executeLocal: true, + }, + { + name: "usage", + description: "Show token usage", + icon: "barChart", + category: "tools", + executeLocal: true, + }, + + // ── Agents ── + { + name: "agents", + description: "List agents", + icon: "monitor", + category: "agents", + executeLocal: true, + }, + { + name: "kill", + description: "Abort sub-agents", + args: "", + icon: "x", + category: "agents", + executeLocal: true, + }, + { + name: "skill", + description: "Run a skill", + args: "", + icon: "zap", + category: "tools", + }, + { + name: "steer", + description: "Steer a sub-agent", + args: " ", + icon: "send", + category: "agents", + }, +]; + +const CATEGORY_ORDER: SlashCommandCategory[] = ["session", "model", "tools", "agents"]; + +export const CATEGORY_LABELS: Record = { + session: "Session", + model: "Model", + agents: "Agents", + tools: "Tools", +}; + +export function getSlashCommandCompletions(filter: string): SlashCommandDef[] { + const lower = filter.toLowerCase(); + const commands = lower + ? SLASH_COMMANDS.filter( + (cmd) => cmd.name.startsWith(lower) || cmd.description.toLowerCase().includes(lower), + ) + : SLASH_COMMANDS; + return commands.toSorted((a, b) => { + const ai = CATEGORY_ORDER.indexOf(a.category ?? "session"); + const bi = CATEGORY_ORDER.indexOf(b.category ?? "session"); + if (ai !== bi) { + return ai - bi; + } + // Exact prefix matches first + if (lower) { + const aExact = a.name.startsWith(lower) ? 0 : 1; + const bExact = b.name.startsWith(lower) ? 0 : 1; + if (aExact !== bExact) { + return aExact - bExact; + } + } + return 0; + }); +} + +export type ParsedSlashCommand = { + command: SlashCommandDef; + args: string; +}; + +/** + * Parse a message as a slash command. Returns null if it doesn't match. + * Supports `/command` and `/command args...`. + */ +export function parseSlashCommand(text: string): ParsedSlashCommand | null { + const trimmed = text.trim(); + if (!trimmed.startsWith("/")) { + return null; + } + + const spaceIdx = trimmed.indexOf(" "); + const name = spaceIdx === -1 ? trimmed.slice(1) : trimmed.slice(1, spaceIdx); + const args = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim(); + + if (!name) { + return null; + } + + const command = SLASH_COMMANDS.find((cmd) => cmd.name === name.toLowerCase()); + if (!command) { + return null; + } + + return { command, args }; +} diff --git a/ui/src/ui/chat/speech.ts b/ui/src/ui/chat/speech.ts new file mode 100644 index 00000000000..4db4e6944a1 --- /dev/null +++ b/ui/src/ui/chat/speech.ts @@ -0,0 +1,225 @@ +/** + * Browser-native speech services: STT via SpeechRecognition, TTS via SpeechSynthesis. + * Falls back gracefully when APIs are unavailable. + */ + +// ─── STT (Speech-to-Text) ─── + +type SpeechRecognitionEvent = Event & { + results: SpeechRecognitionResultList; + resultIndex: number; +}; + +type SpeechRecognitionErrorEvent = Event & { + error: string; + message?: string; +}; + +interface SpeechRecognitionInstance extends EventTarget { + continuous: boolean; + interimResults: boolean; + lang: string; + start(): void; + stop(): void; + abort(): void; + onresult: ((event: SpeechRecognitionEvent) => void) | null; + onerror: ((event: SpeechRecognitionErrorEvent) => void) | null; + onend: (() => void) | null; + onstart: (() => void) | null; +} + +type SpeechRecognitionCtor = new () => SpeechRecognitionInstance; + +function getSpeechRecognitionCtor(): SpeechRecognitionCtor | null { + const w = globalThis as Record; + return (w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null) as SpeechRecognitionCtor | null; +} + +export function isSttSupported(): boolean { + return getSpeechRecognitionCtor() !== null; +} + +export type SttCallbacks = { + onTranscript: (text: string, isFinal: boolean) => void; + onStart?: () => void; + onEnd?: () => void; + onError?: (error: string) => void; +}; + +let activeRecognition: SpeechRecognitionInstance | null = null; + +export function startStt(callbacks: SttCallbacks): boolean { + const Ctor = getSpeechRecognitionCtor(); + if (!Ctor) { + callbacks.onError?.("Speech recognition is not supported in this browser"); + return false; + } + + stopStt(); + + const recognition = new Ctor(); + recognition.continuous = true; + recognition.interimResults = true; + recognition.lang = navigator.language || "en-US"; + + recognition.addEventListener("start", () => callbacks.onStart?.()); + + recognition.addEventListener("result", (event) => { + const speechEvent = event as unknown as SpeechRecognitionEvent; + let interimTranscript = ""; + let finalTranscript = ""; + + for (let i = speechEvent.resultIndex; i < speechEvent.results.length; i++) { + const result = speechEvent.results[i]; + if (!result?.[0]) { + continue; + } + const transcript = result[0].transcript; + if (result.isFinal) { + finalTranscript += transcript; + } else { + interimTranscript += transcript; + } + } + + if (finalTranscript) { + callbacks.onTranscript(finalTranscript, true); + } else if (interimTranscript) { + callbacks.onTranscript(interimTranscript, false); + } + }); + + recognition.addEventListener("error", (event) => { + const speechEvent = event as unknown as SpeechRecognitionErrorEvent; + if (speechEvent.error === "aborted" || speechEvent.error === "no-speech") { + return; + } + callbacks.onError?.(speechEvent.error); + }); + + recognition.addEventListener("end", () => { + if (activeRecognition === recognition) { + activeRecognition = null; + } + callbacks.onEnd?.(); + }); + + activeRecognition = recognition; + recognition.start(); + return true; +} + +export function stopStt(): void { + if (activeRecognition) { + const r = activeRecognition; + activeRecognition = null; + try { + r.stop(); + } catch { + // already stopped + } + } +} + +export function isSttActive(): boolean { + return activeRecognition !== null; +} + +// ─── TTS (Text-to-Speech) ─── + +export function isTtsSupported(): boolean { + return "speechSynthesis" in globalThis; +} + +let currentUtterance: SpeechSynthesisUtterance | null = null; + +export function speakText( + text: string, + opts?: { + onStart?: () => void; + onEnd?: () => void; + onError?: (error: string) => void; + }, +): boolean { + if (!isTtsSupported()) { + opts?.onError?.("Speech synthesis is not supported in this browser"); + return false; + } + + stopTts(); + + const cleaned = stripMarkdown(text); + if (!cleaned.trim()) { + return false; + } + + const utterance = new SpeechSynthesisUtterance(cleaned); + utterance.rate = 1.0; + utterance.pitch = 1.0; + + utterance.addEventListener("start", () => opts?.onStart?.()); + utterance.addEventListener("end", () => { + if (currentUtterance === utterance) { + currentUtterance = null; + } + opts?.onEnd?.(); + }); + utterance.addEventListener("error", (e) => { + if (currentUtterance === utterance) { + currentUtterance = null; + } + if (e.error === "canceled" || e.error === "interrupted") { + return; + } + opts?.onError?.(e.error); + }); + + currentUtterance = utterance; + speechSynthesis.speak(utterance); + return true; +} + +export function stopTts(): void { + if (currentUtterance) { + currentUtterance = null; + } + if (isTtsSupported()) { + speechSynthesis.cancel(); + } +} + +export function isTtsSpeaking(): boolean { + return isTtsSupported() && speechSynthesis.speaking; +} + +/** Strip common markdown syntax for cleaner speech output. */ +function stripMarkdown(text: string): string { + return ( + text + // code blocks + .replace(/```[\s\S]*?```/g, "") + // inline code + .replace(/`[^`]+`/g, "") + // images + .replace(/!\[.*?\]\(.*?\)/g, "") + // links → keep text + .replace(/\[([^\]]+)\]\(.*?\)/g, "$1") + // headings + .replace(/^#{1,6}\s+/gm, "") + // bold/italic + .replace(/\*{1,3}(.*?)\*{1,3}/g, "$1") + .replace(/_{1,3}(.*?)_{1,3}/g, "$1") + // blockquotes + .replace(/^>\s?/gm, "") + // horizontal rules + .replace(/^[-*_]{3,}\s*$/gm, "") + // list markers + .replace(/^\s*[-*+]\s+/gm, "") + .replace(/^\s*\d+\.\s+/gm, "") + // HTML tags + .replace(/<[^>]+>/g, "") + // collapse whitespace + .replace(/\n{3,}/g, "\n\n") + .trim() + ); +} From d648dd7643dc1232cc1a9071391fad0587097ca8 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:07:03 -0500 Subject: [PATCH 07/11] Update ui/src/ui/chat/export.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- ui/src/ui/chat/export.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ui/src/ui/chat/export.ts b/ui/src/ui/chat/export.ts index 31e15e592e2..365d640ffcc 100644 --- a/ui/src/ui/chat/export.ts +++ b/ui/src/ui/chat/export.ts @@ -10,7 +10,15 @@ export function exportChatMarkdown(messages: unknown[], assistantName: string): for (const msg of history) { const m = msg as Record; const role = m.role === "user" ? "You" : m.role === "assistant" ? assistantName : "Tool"; - const content = typeof m.content === "string" ? m.content : ""; + const content = + typeof m.content === "string" + ? m.content + : Array.isArray(m.content) + ? (m.content as Array<{ type?: string; text?: string }>) + .filter((b) => b?.type === "text" && typeof b.text === "string") + .map((b) => b.text) + .join("") + : ""; const ts = typeof m.timestamp === "number" ? new Date(m.timestamp).toISOString() : ""; lines.push(`## ${role}${ts ? ` (${ts})` : ""}`, "", content, ""); } From 8a6cd808a138ea73e53b7498bc3fd5dcfd565a7a Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 9 Mar 2026 18:09:37 -0500 Subject: [PATCH 08/11] fix(ui): address review feedback on chat infra slice - export.ts: handle array content blocks (Claude API format) instead of silently exporting empty strings - slash-command-executor.ts: restrict /kill all to current session's subagent subtree instead of all sessions globally - slash-command-executor.ts: only count truly aborted runs (check aborted !== false) in /kill summary --- ui/src/ui/chat/slash-command-executor.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/ui/src/ui/chat/slash-command-executor.ts b/ui/src/ui/chat/slash-command-executor.ts index 3392095c7c1..d1c767370a4 100644 --- a/ui/src/ui/chat/slash-command-executor.ts +++ b/ui/src/ui/chat/slash-command-executor.ts @@ -296,9 +296,14 @@ async function executeKill( } const results = await Promise.allSettled( - matched.map((key) => client.request("chat.abort", { sessionKey: key })), + matched.map((key) => + client.request<{ aborted?: boolean }>("chat.abort", { sessionKey: key }), + ), ); - const successCount = results.filter((entry) => entry.status === "fulfilled").length; + const successCount = results.filter( + (entry) => + entry.status === "fulfilled" && (entry.value as { aborted?: boolean })?.aborted !== false, + ).length; if (successCount === 0) { const firstFailure = results.find((entry) => entry.status === "rejected"); throw firstFailure?.reason ?? new Error("abort failed"); @@ -343,15 +348,16 @@ function resolveKillTargets( } const normalizedKey = key.toLowerCase(); const parsed = parseAgentSessionKey(normalizedKey); + // For "all", only match subagents belonging to the current session's agent + const belongsToCurrentSession = + currentParsed?.agentId != null && parsed?.agentId === currentParsed.agentId; const isMatch = - normalizedTarget === "all" || + (normalizedTarget === "all" && belongsToCurrentSession) || normalizedKey === normalizedTarget || (parsed?.agentId ?? "") === normalizedTarget || normalizedKey.endsWith(`:subagent:${normalizedTarget}`) || normalizedKey === `subagent:${normalizedTarget}` || - (currentParsed?.agentId != null && - parsed?.agentId === currentParsed.agentId && - normalizedKey.endsWith(`:subagent:${normalizedTarget}`)); + (belongsToCurrentSession && normalizedKey.endsWith(`:subagent:${normalizedTarget}`)); if (isMatch) { keys.add(key); } From 8e412bad0ebe41264dc4cf169b1fdd8453f6b000 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 9 Mar 2026 18:47:37 -0500 Subject: [PATCH 09/11] Revert "fix(ui): address review feedback on chat infra slice" This reverts commit 8a6cd808a138ea73e53b7498bc3fd5dcfd565a7a. --- ui/src/ui/chat/slash-command-executor.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/ui/src/ui/chat/slash-command-executor.ts b/ui/src/ui/chat/slash-command-executor.ts index d1c767370a4..3392095c7c1 100644 --- a/ui/src/ui/chat/slash-command-executor.ts +++ b/ui/src/ui/chat/slash-command-executor.ts @@ -296,14 +296,9 @@ async function executeKill( } const results = await Promise.allSettled( - matched.map((key) => - client.request<{ aborted?: boolean }>("chat.abort", { sessionKey: key }), - ), + matched.map((key) => client.request("chat.abort", { sessionKey: key })), ); - const successCount = results.filter( - (entry) => - entry.status === "fulfilled" && (entry.value as { aborted?: boolean })?.aborted !== false, - ).length; + const successCount = results.filter((entry) => entry.status === "fulfilled").length; if (successCount === 0) { const firstFailure = results.find((entry) => entry.status === "rejected"); throw firstFailure?.reason ?? new Error("abort failed"); @@ -348,16 +343,15 @@ function resolveKillTargets( } const normalizedKey = key.toLowerCase(); const parsed = parseAgentSessionKey(normalizedKey); - // For "all", only match subagents belonging to the current session's agent - const belongsToCurrentSession = - currentParsed?.agentId != null && parsed?.agentId === currentParsed.agentId; const isMatch = - (normalizedTarget === "all" && belongsToCurrentSession) || + normalizedTarget === "all" || normalizedKey === normalizedTarget || (parsed?.agentId ?? "") === normalizedTarget || normalizedKey.endsWith(`:subagent:${normalizedTarget}`) || normalizedKey === `subagent:${normalizedTarget}` || - (belongsToCurrentSession && normalizedKey.endsWith(`:subagent:${normalizedTarget}`)); + (currentParsed?.agentId != null && + parsed?.agentId === currentParsed.agentId && + normalizedKey.endsWith(`:subagent:${normalizedTarget}`)); if (isMatch) { keys.add(key); } From 9f0a64f855439979abd79a6e9e52d171c994482f Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 9 Mar 2026 18:47:40 -0500 Subject: [PATCH 10/11] Revert "Update ui/src/ui/chat/export.ts" This reverts commit d648dd7643dc1232cc1a9071391fad0587097ca8. --- ui/src/ui/chat/export.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/ui/src/ui/chat/export.ts b/ui/src/ui/chat/export.ts index 365d640ffcc..31e15e592e2 100644 --- a/ui/src/ui/chat/export.ts +++ b/ui/src/ui/chat/export.ts @@ -10,15 +10,7 @@ export function exportChatMarkdown(messages: unknown[], assistantName: string): for (const msg of history) { const m = msg as Record; const role = m.role === "user" ? "You" : m.role === "assistant" ? assistantName : "Tool"; - const content = - typeof m.content === "string" - ? m.content - : Array.isArray(m.content) - ? (m.content as Array<{ type?: string; text?: string }>) - .filter((b) => b?.type === "text" && typeof b.text === "string") - .map((b) => b.text) - .join("") - : ""; + const content = typeof m.content === "string" ? m.content : ""; const ts = typeof m.timestamp === "number" ? new Date(m.timestamp).toISOString() : ""; lines.push(`## ${role}${ts ? ` (${ts})` : ""}`, "", content, ""); } From 6b8748989061c1d3405002e67004cd7574042717 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 9 Mar 2026 18:47:44 -0500 Subject: [PATCH 11/11] Revert "feat(ui): add chat infrastructure modules (slice 1 of dashboard-v2)" This reverts commit 5a659b0b61dbfa1645fdfa28bf9bffee03a8c9bc. --- src/gateway/server-methods/chat.ts | 93 +---- .../server.chat.gateway-server-chat-b.test.ts | 31 -- ui/src/ui/chat-export.ts | 1 - ui/src/ui/chat/deleted-messages.ts | 49 --- ui/src/ui/chat/export.ts | 24 -- ui/src/ui/chat/input-history.ts | 49 --- ui/src/ui/chat/pinned-messages.ts | 61 --- .../chat/slash-command-executor.node.test.ts | 83 ---- ui/src/ui/chat/slash-command-executor.ts | 370 ------------------ ui/src/ui/chat/slash-commands.ts | 217 ---------- ui/src/ui/chat/speech.ts | 225 ----------- 11 files changed, 7 insertions(+), 1196 deletions(-) delete mode 100644 ui/src/ui/chat-export.ts delete mode 100644 ui/src/ui/chat/deleted-messages.ts delete mode 100644 ui/src/ui/chat/export.ts delete mode 100644 ui/src/ui/chat/input-history.ts delete mode 100644 ui/src/ui/chat/pinned-messages.ts delete mode 100644 ui/src/ui/chat/slash-command-executor.node.test.ts delete mode 100644 ui/src/ui/chat/slash-command-executor.ts delete mode 100644 ui/src/ui/chat/slash-commands.ts delete mode 100644 ui/src/ui/chat/speech.ts diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 291e323b671..71669080382 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -314,60 +314,6 @@ function sanitizeChatHistoryContentBlock(block: unknown): { block: unknown; chan return { block: changed ? entry : block, changed }; } -/** - * Validate that a value is a finite number, returning undefined otherwise. - */ -function toFiniteNumber(x: unknown): number | undefined { - return typeof x === "number" && Number.isFinite(x) ? x : undefined; -} - -/** - * Sanitize usage metadata to ensure only finite numeric fields are included. - * Prevents UI crashes from malformed transcript JSON. - */ -function sanitizeUsage(raw: unknown): Record | undefined { - if (!raw || typeof raw !== "object") { - return undefined; - } - const u = raw as Record; - const out: Record = {}; - - // Whitelist known usage fields and validate they're finite numbers - const knownFields = [ - "input", - "output", - "totalTokens", - "inputTokens", - "outputTokens", - "cacheRead", - "cacheWrite", - "cache_read_input_tokens", - "cache_creation_input_tokens", - ]; - - for (const k of knownFields) { - const n = toFiniteNumber(u[k]); - if (n !== undefined) { - out[k] = n; - } - } - - return Object.keys(out).length > 0 ? out : undefined; -} - -/** - * Sanitize cost metadata to ensure only finite numeric fields are included. - * Prevents UI crashes from calling .toFixed() on non-numbers. - */ -function sanitizeCost(raw: unknown): { total?: number } | undefined { - if (!raw || typeof raw !== "object") { - return undefined; - } - const c = raw as Record; - const total = toFiniteNumber(c.total); - return total !== undefined ? { total } : undefined; -} - function sanitizeChatHistoryMessage(message: unknown): { message: unknown; changed: boolean } { if (!message || typeof message !== "object") { return { message, changed: false }; @@ -379,38 +325,13 @@ function sanitizeChatHistoryMessage(message: unknown): { message: unknown; chang delete entry.details; changed = true; } - - // Keep usage/cost so the chat UI can render per-message token and cost badges. - // Only retain usage/cost on assistant messages and validate numeric fields to prevent UI crashes. - if (entry.role !== "assistant") { - if ("usage" in entry) { - delete entry.usage; - changed = true; - } - if ("cost" in entry) { - delete entry.cost; - changed = true; - } - } else { - // Validate and sanitize usage/cost for assistant messages - if ("usage" in entry) { - const sanitized = sanitizeUsage(entry.usage); - if (sanitized) { - entry.usage = sanitized; - } else { - delete entry.usage; - } - changed = true; - } - if ("cost" in entry) { - const sanitized = sanitizeCost(entry.cost); - if (sanitized) { - entry.cost = sanitized; - } else { - delete entry.cost; - } - changed = true; - } + if ("usage" in entry) { + delete entry.usage; + changed = true; + } + if ("cost" in entry) { + delete entry.cost; + changed = true; } if (typeof entry.content === "string") { 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 ca1e2c09402..2e76e1a5de1 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts @@ -273,37 +273,6 @@ 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/chat-export.ts b/ui/src/ui/chat-export.ts deleted file mode 100644 index ed5bbf931f8..00000000000 --- a/ui/src/ui/chat-export.ts +++ /dev/null @@ -1 +0,0 @@ -export { exportChatMarkdown } from "./chat/export.ts"; diff --git a/ui/src/ui/chat/deleted-messages.ts b/ui/src/ui/chat/deleted-messages.ts deleted file mode 100644 index fd3916d78c7..00000000000 --- a/ui/src/ui/chat/deleted-messages.ts +++ /dev/null @@ -1,49 +0,0 @@ -const PREFIX = "openclaw:deleted:"; - -export class DeletedMessages { - private key: string; - private _keys = new Set(); - - constructor(sessionKey: string) { - this.key = PREFIX + sessionKey; - this.load(); - } - - has(key: string): boolean { - return this._keys.has(key); - } - - delete(key: string): void { - this._keys.add(key); - this.save(); - } - - restore(key: string): void { - this._keys.delete(key); - this.save(); - } - - clear(): void { - this._keys.clear(); - this.save(); - } - - private load(): void { - try { - const raw = localStorage.getItem(this.key); - if (!raw) { - return; - } - const arr = JSON.parse(raw); - if (Array.isArray(arr)) { - this._keys = new Set(arr.filter((s) => typeof s === "string")); - } - } catch { - // ignore - } - } - - private save(): void { - localStorage.setItem(this.key, JSON.stringify([...this._keys])); - } -} diff --git a/ui/src/ui/chat/export.ts b/ui/src/ui/chat/export.ts deleted file mode 100644 index 31e15e592e2..00000000000 --- a/ui/src/ui/chat/export.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Export chat history as markdown file. - */ -export function exportChatMarkdown(messages: unknown[], assistantName: string): void { - const history = Array.isArray(messages) ? messages : []; - if (history.length === 0) { - return; - } - const lines: string[] = [`# Chat with ${assistantName}`, ""]; - for (const msg of history) { - const m = msg as Record; - const role = m.role === "user" ? "You" : m.role === "assistant" ? assistantName : "Tool"; - const content = typeof m.content === "string" ? m.content : ""; - const ts = typeof m.timestamp === "number" ? new Date(m.timestamp).toISOString() : ""; - lines.push(`## ${role}${ts ? ` (${ts})` : ""}`, "", content, ""); - } - const blob = new Blob([lines.join("\n")], { type: "text/markdown" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = `chat-${assistantName}-${Date.now()}.md`; - link.click(); - URL.revokeObjectURL(url); -} diff --git a/ui/src/ui/chat/input-history.ts b/ui/src/ui/chat/input-history.ts deleted file mode 100644 index 34d8806d072..00000000000 --- a/ui/src/ui/chat/input-history.ts +++ /dev/null @@ -1,49 +0,0 @@ -const MAX = 50; - -export class InputHistory { - private items: string[] = []; - private cursor = -1; - - push(text: string): void { - const trimmed = text.trim(); - if (!trimmed) { - return; - } - if (this.items[this.items.length - 1] === trimmed) { - return; - } - this.items.push(trimmed); - if (this.items.length > MAX) { - this.items.shift(); - } - this.cursor = -1; - } - - up(): string | null { - if (this.items.length === 0) { - return null; - } - if (this.cursor < 0) { - this.cursor = this.items.length - 1; - } else if (this.cursor > 0) { - this.cursor--; - } - return this.items[this.cursor] ?? null; - } - - down(): string | null { - if (this.cursor < 0) { - return null; - } - this.cursor++; - if (this.cursor >= this.items.length) { - this.cursor = -1; - return null; - } - return this.items[this.cursor] ?? null; - } - - reset(): void { - this.cursor = -1; - } -} diff --git a/ui/src/ui/chat/pinned-messages.ts b/ui/src/ui/chat/pinned-messages.ts deleted file mode 100644 index 4914b0db32a..00000000000 --- a/ui/src/ui/chat/pinned-messages.ts +++ /dev/null @@ -1,61 +0,0 @@ -const PREFIX = "openclaw:pinned:"; - -export class PinnedMessages { - private key: string; - private _indices = new Set(); - - constructor(sessionKey: string) { - this.key = PREFIX + sessionKey; - this.load(); - } - - get indices(): Set { - return this._indices; - } - - has(index: number): boolean { - return this._indices.has(index); - } - - pin(index: number): void { - this._indices.add(index); - this.save(); - } - - unpin(index: number): void { - this._indices.delete(index); - this.save(); - } - - toggle(index: number): void { - if (this._indices.has(index)) { - this.unpin(index); - } else { - this.pin(index); - } - } - - clear(): void { - this._indices.clear(); - this.save(); - } - - private load(): void { - try { - const raw = localStorage.getItem(this.key); - if (!raw) { - return; - } - const arr = JSON.parse(raw); - if (Array.isArray(arr)) { - this._indices = new Set(arr.filter((n) => typeof n === "number")); - } - } catch { - // ignore - } - } - - private save(): void { - localStorage.setItem(this.key, JSON.stringify([...this._indices])); - } -} diff --git a/ui/src/ui/chat/slash-command-executor.node.test.ts b/ui/src/ui/chat/slash-command-executor.node.test.ts deleted file mode 100644 index 706bfed0c3c..00000000000 --- a/ui/src/ui/chat/slash-command-executor.node.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { GatewayBrowserClient } from "../gateway.ts"; -import type { GatewaySessionRow } from "../types.ts"; -import { executeSlashCommand } from "./slash-command-executor.ts"; - -function row(key: string): GatewaySessionRow { - return { - key, - kind: "direct", - updatedAt: null, - }; -} - -describe("executeSlashCommand /kill", () => { - it("aborts every sub-agent session for /kill all", async () => { - const request = vi.fn(async (method: string, _payload?: unknown) => { - if (method === "sessions.list") { - return { - sessions: [ - row("main"), - row("agent:main:subagent:one"), - row("agent:main:subagent:parent:subagent:child"), - row("agent:other:main"), - ], - }; - } - if (method === "chat.abort") { - return { ok: true, aborted: true }; - } - throw new Error(`unexpected method: ${method}`); - }); - - const result = await executeSlashCommand( - { request } as unknown as GatewayBrowserClient, - "agent:main:main", - "kill", - "all", - ); - - expect(result.content).toBe("Aborted 2 sub-agent sessions."); - expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); - expect(request).toHaveBeenNthCalledWith(2, "chat.abort", { - sessionKey: "agent:main:subagent:one", - }); - expect(request).toHaveBeenNthCalledWith(3, "chat.abort", { - sessionKey: "agent:main:subagent:parent:subagent:child", - }); - }); - - it("aborts matching sub-agent sessions for /kill ", async () => { - const request = vi.fn(async (method: string, _payload?: unknown) => { - if (method === "sessions.list") { - return { - sessions: [ - row("agent:main:subagent:one"), - row("agent:main:subagent:two"), - row("agent:other:subagent:three"), - ], - }; - } - if (method === "chat.abort") { - return { ok: true, aborted: true }; - } - throw new Error(`unexpected method: ${method}`); - }); - - const result = await executeSlashCommand( - { request } as unknown as GatewayBrowserClient, - "agent:main:main", - "kill", - "main", - ); - - expect(result.content).toBe("Aborted 2 matching sub-agent sessions for `main`."); - expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); - expect(request).toHaveBeenNthCalledWith(2, "chat.abort", { - sessionKey: "agent:main:subagent:one", - }); - expect(request).toHaveBeenNthCalledWith(3, "chat.abort", { - sessionKey: "agent:main:subagent:two", - }); - }); -}); diff --git a/ui/src/ui/chat/slash-command-executor.ts b/ui/src/ui/chat/slash-command-executor.ts deleted file mode 100644 index 3392095c7c1..00000000000 --- a/ui/src/ui/chat/slash-command-executor.ts +++ /dev/null @@ -1,370 +0,0 @@ -/** - * Client-side execution engine for slash commands. - * Calls gateway RPC methods and returns formatted results. - */ - -import { isSubagentSessionKey, parseAgentSessionKey } from "../../../../src/routing/session-key.js"; -import type { GatewayBrowserClient } from "../gateway.ts"; -import type { - AgentsListResult, - GatewaySessionRow, - HealthSummary, - ModelCatalogEntry, - SessionsListResult, -} from "../types.ts"; -import { SLASH_COMMANDS } from "./slash-commands.ts"; - -export type SlashCommandResult = { - /** Markdown-formatted result to display in chat. */ - content: string; - /** Side-effect action the caller should perform after displaying the result. */ - action?: - | "refresh" - | "export" - | "new-session" - | "reset" - | "stop" - | "clear" - | "toggle-focus" - | "navigate-usage"; -}; - -export async function executeSlashCommand( - client: GatewayBrowserClient, - sessionKey: string, - commandName: string, - args: string, -): Promise { - switch (commandName) { - case "help": - return executeHelp(); - case "status": - return await executeStatus(client); - case "new": - return { content: "Starting new session...", action: "new-session" }; - case "reset": - return { content: "Resetting session...", action: "reset" }; - case "stop": - return { content: "Stopping current run...", action: "stop" }; - case "clear": - return { content: "Chat history cleared.", action: "clear" }; - case "focus": - return { content: "Toggled focus mode.", action: "toggle-focus" }; - case "compact": - return await executeCompact(client, sessionKey); - case "model": - return await executeModel(client, sessionKey, args); - case "think": - return await executeThink(client, sessionKey, args); - case "verbose": - return await executeVerbose(client, sessionKey, args); - case "export": - return { content: "Exporting session...", action: "export" }; - case "usage": - return await executeUsage(client, sessionKey); - case "agents": - return await executeAgents(client); - case "kill": - return await executeKill(client, sessionKey, args); - default: - return { content: `Unknown command: \`/${commandName}\`` }; - } -} - -// ── Command Implementations ── - -function executeHelp(): SlashCommandResult { - const lines = ["**Available Commands**\n"]; - let currentCategory = ""; - - for (const cmd of SLASH_COMMANDS) { - const cat = cmd.category ?? "session"; - if (cat !== currentCategory) { - currentCategory = cat; - lines.push(`**${cat.charAt(0).toUpperCase() + cat.slice(1)}**`); - } - const argStr = cmd.args ? ` ${cmd.args}` : ""; - const local = cmd.executeLocal ? "" : " *(agent)*"; - lines.push(`\`/${cmd.name}${argStr}\` — ${cmd.description}${local}`); - } - - lines.push("\nType `/` to open the command menu."); - return { content: lines.join("\n") }; -} - -async function executeStatus(client: GatewayBrowserClient): Promise { - try { - const health = await client.request("health", {}); - const status = health.ok ? "Healthy" : "Degraded"; - const agentCount = health.agents?.length ?? 0; - const sessionCount = health.sessions?.count ?? 0; - const lines = [ - `**System Status:** ${status}`, - `**Agents:** ${agentCount}`, - `**Sessions:** ${sessionCount}`, - `**Default Agent:** ${health.defaultAgentId || "none"}`, - ]; - if (health.durationMs) { - lines.push(`**Response:** ${health.durationMs}ms`); - } - return { content: lines.join("\n") }; - } catch (err) { - return { content: `Failed to fetch status: ${String(err)}` }; - } -} - -async function executeCompact( - client: GatewayBrowserClient, - sessionKey: string, -): Promise { - try { - await client.request("sessions.compact", { key: sessionKey }); - return { content: "Context compacted successfully.", action: "refresh" }; - } catch (err) { - return { content: `Compaction failed: ${String(err)}` }; - } -} - -async function executeModel( - client: GatewayBrowserClient, - sessionKey: string, - args: string, -): Promise { - if (!args) { - try { - const sessions = await client.request("sessions.list", {}); - const session = sessions?.sessions?.find((s: GatewaySessionRow) => s.key === sessionKey); - const model = session?.model || sessions?.defaults?.model || "default"; - const models = await client.request<{ models: ModelCatalogEntry[] }>("models.list", {}); - const available = models?.models?.map((m: ModelCatalogEntry) => m.id) ?? []; - const lines = [`**Current model:** \`${model}\``]; - if (available.length > 0) { - lines.push( - `**Available:** ${available - .slice(0, 10) - .map((m: string) => `\`${m}\``) - .join(", ")}${available.length > 10 ? ` +${available.length - 10} more` : ""}`, - ); - } - return { content: lines.join("\n") }; - } catch (err) { - return { content: `Failed to get model info: ${String(err)}` }; - } - } - - try { - await client.request("sessions.patch", { key: sessionKey, model: args.trim() }); - return { content: `Model set to \`${args.trim()}\`.`, action: "refresh" }; - } catch (err) { - return { content: `Failed to set model: ${String(err)}` }; - } -} - -async function executeThink( - client: GatewayBrowserClient, - sessionKey: string, - args: string, -): Promise { - const valid = ["off", "low", "medium", "high"]; - const level = args.trim().toLowerCase(); - - if (!level) { - return { - content: `Usage: \`/think <${valid.join("|")}>\``, - }; - } - if (!valid.includes(level)) { - return { - content: `Invalid thinking level \`${level}\`. Choose: ${valid.map((v) => `\`${v}\``).join(", ")}`, - }; - } - - try { - await client.request("sessions.patch", { key: sessionKey, thinkingLevel: level }); - return { - content: `Thinking level set to **${level}**.`, - action: "refresh", - }; - } catch (err) { - return { content: `Failed to set thinking level: ${String(err)}` }; - } -} - -async function executeVerbose( - client: GatewayBrowserClient, - sessionKey: string, - args: string, -): Promise { - const valid = ["on", "off", "full"]; - const level = args.trim().toLowerCase(); - - if (!level) { - return { - content: `Usage: \`/verbose <${valid.join("|")}>\``, - }; - } - if (!valid.includes(level)) { - return { - content: `Invalid verbose level \`${level}\`. Choose: ${valid.map((v) => `\`${v}\``).join(", ")}`, - }; - } - - try { - await client.request("sessions.patch", { key: sessionKey, verboseLevel: level }); - return { - content: `Verbose mode set to **${level}**.`, - action: "refresh", - }; - } catch (err) { - return { content: `Failed to set verbose mode: ${String(err)}` }; - } -} - -async function executeUsage( - client: GatewayBrowserClient, - sessionKey: string, -): Promise { - try { - const sessions = await client.request("sessions.list", {}); - const session = sessions?.sessions?.find((s: GatewaySessionRow) => s.key === sessionKey); - if (!session) { - return { content: "No active session." }; - } - const input = session.inputTokens ?? 0; - const output = session.outputTokens ?? 0; - const total = session.totalTokens ?? input + output; - const ctx = session.contextTokens ?? 0; - const pct = ctx > 0 ? Math.round((input / ctx) * 100) : null; - - const lines = [ - "**Session Usage**", - `Input: **${fmtTokens(input)}** tokens`, - `Output: **${fmtTokens(output)}** tokens`, - `Total: **${fmtTokens(total)}** tokens`, - ]; - if (pct !== null) { - lines.push(`Context: **${pct}%** of ${fmtTokens(ctx)}`); - } - if (session.model) { - lines.push(`Model: \`${session.model}\``); - } - return { content: lines.join("\n") }; - } catch (err) { - return { content: `Failed to get usage: ${String(err)}` }; - } -} - -async function executeAgents(client: GatewayBrowserClient): Promise { - try { - const result = await client.request("agents.list", {}); - const agents = result?.agents ?? []; - if (agents.length === 0) { - return { content: "No agents configured." }; - } - const lines = [`**Agents** (${agents.length})\n`]; - for (const agent of agents) { - const isDefault = agent.id === result?.defaultId; - const name = agent.identity?.name || agent.name || agent.id; - const marker = isDefault ? " *(default)*" : ""; - lines.push(`- \`${agent.id}\` — ${name}${marker}`); - } - return { content: lines.join("\n") }; - } catch (err) { - return { content: `Failed to list agents: ${String(err)}` }; - } -} - -async function executeKill( - client: GatewayBrowserClient, - sessionKey: string, - args: string, -): Promise { - const target = args.trim(); - if (!target) { - return { content: "Usage: `/kill `" }; - } - try { - const sessions = await client.request("sessions.list", {}); - const matched = resolveKillTargets(sessions?.sessions ?? [], sessionKey, target); - if (matched.length === 0) { - return { - content: - target.toLowerCase() === "all" - ? "No active sub-agent sessions found." - : `No matching sub-agent sessions found for \`${target}\`.`, - }; - } - - const results = await Promise.allSettled( - matched.map((key) => client.request("chat.abort", { sessionKey: key })), - ); - const successCount = results.filter((entry) => entry.status === "fulfilled").length; - if (successCount === 0) { - const firstFailure = results.find((entry) => entry.status === "rejected"); - throw firstFailure?.reason ?? new Error("abort failed"); - } - - if (target.toLowerCase() === "all") { - return { - content: - successCount === matched.length - ? `Aborted ${successCount} sub-agent session${successCount === 1 ? "" : "s"}.` - : `Aborted ${successCount} of ${matched.length} sub-agent sessions.`, - }; - } - - return { - content: - successCount === matched.length - ? `Aborted ${successCount} matching sub-agent session${successCount === 1 ? "" : "s"} for \`${target}\`.` - : `Aborted ${successCount} of ${matched.length} matching sub-agent sessions for \`${target}\`.`, - }; - } catch (err) { - return { content: `Failed to abort: ${String(err)}` }; - } -} - -function resolveKillTargets( - sessions: GatewaySessionRow[], - currentSessionKey: string, - target: string, -): string[] { - const normalizedTarget = target.trim().toLowerCase(); - if (!normalizedTarget) { - return []; - } - - const keys = new Set(); - const currentParsed = parseAgentSessionKey(currentSessionKey); - for (const session of sessions) { - const key = session?.key?.trim(); - if (!key || !isSubagentSessionKey(key)) { - continue; - } - const normalizedKey = key.toLowerCase(); - const parsed = parseAgentSessionKey(normalizedKey); - const isMatch = - normalizedTarget === "all" || - normalizedKey === normalizedTarget || - (parsed?.agentId ?? "") === normalizedTarget || - normalizedKey.endsWith(`:subagent:${normalizedTarget}`) || - normalizedKey === `subagent:${normalizedTarget}` || - (currentParsed?.agentId != null && - parsed?.agentId === currentParsed.agentId && - normalizedKey.endsWith(`:subagent:${normalizedTarget}`)); - if (isMatch) { - keys.add(key); - } - } - return [...keys]; -} - -function fmtTokens(n: number): string { - if (n >= 1_000_000) { - return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; - } - if (n >= 1_000) { - return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`; - } - return String(n); -} diff --git a/ui/src/ui/chat/slash-commands.ts b/ui/src/ui/chat/slash-commands.ts deleted file mode 100644 index d26a82e1544..00000000000 --- a/ui/src/ui/chat/slash-commands.ts +++ /dev/null @@ -1,217 +0,0 @@ -import type { IconName } from "../icons.ts"; - -export type SlashCommandCategory = "session" | "model" | "agents" | "tools"; - -export type SlashCommandDef = { - name: string; - description: string; - args?: string; - icon?: IconName; - category?: SlashCommandCategory; - /** When true, the command is executed client-side via RPC instead of sent to the agent. */ - executeLocal?: boolean; - /** Fixed argument choices for inline hints. */ - argOptions?: string[]; - /** Keyboard shortcut hint shown in the menu (display only). */ - shortcut?: string; -}; - -export const SLASH_COMMANDS: SlashCommandDef[] = [ - // ── Session ── - { - name: "new", - description: "Start a new session", - icon: "plus", - category: "session", - executeLocal: true, - }, - { - name: "reset", - description: "Reset current session", - icon: "refresh", - category: "session", - executeLocal: true, - }, - { - name: "compact", - description: "Compact session context", - icon: "loader", - category: "session", - executeLocal: true, - }, - { - name: "stop", - description: "Stop current run", - icon: "stop", - category: "session", - executeLocal: true, - }, - { - name: "clear", - description: "Clear chat history", - icon: "trash", - category: "session", - executeLocal: true, - }, - { - name: "focus", - description: "Toggle focus mode", - icon: "eye", - category: "session", - executeLocal: true, - }, - - // ── Model ── - { - name: "model", - description: "Show or set model", - args: "", - icon: "brain", - category: "model", - executeLocal: true, - }, - { - name: "think", - description: "Set thinking level", - args: "", - icon: "brain", - category: "model", - executeLocal: true, - argOptions: ["off", "low", "medium", "high"], - }, - { - name: "verbose", - description: "Toggle verbose mode", - args: "", - icon: "terminal", - category: "model", - executeLocal: true, - argOptions: ["on", "off", "full"], - }, - - // ── Tools ── - { - name: "help", - description: "Show available commands", - icon: "book", - category: "tools", - executeLocal: true, - }, - { - name: "status", - description: "Show system status", - icon: "barChart", - category: "tools", - executeLocal: true, - }, - { - name: "export", - description: "Export session to Markdown", - icon: "download", - category: "tools", - executeLocal: true, - }, - { - name: "usage", - description: "Show token usage", - icon: "barChart", - category: "tools", - executeLocal: true, - }, - - // ── Agents ── - { - name: "agents", - description: "List agents", - icon: "monitor", - category: "agents", - executeLocal: true, - }, - { - name: "kill", - description: "Abort sub-agents", - args: "", - icon: "x", - category: "agents", - executeLocal: true, - }, - { - name: "skill", - description: "Run a skill", - args: "", - icon: "zap", - category: "tools", - }, - { - name: "steer", - description: "Steer a sub-agent", - args: " ", - icon: "send", - category: "agents", - }, -]; - -const CATEGORY_ORDER: SlashCommandCategory[] = ["session", "model", "tools", "agents"]; - -export const CATEGORY_LABELS: Record = { - session: "Session", - model: "Model", - agents: "Agents", - tools: "Tools", -}; - -export function getSlashCommandCompletions(filter: string): SlashCommandDef[] { - const lower = filter.toLowerCase(); - const commands = lower - ? SLASH_COMMANDS.filter( - (cmd) => cmd.name.startsWith(lower) || cmd.description.toLowerCase().includes(lower), - ) - : SLASH_COMMANDS; - return commands.toSorted((a, b) => { - const ai = CATEGORY_ORDER.indexOf(a.category ?? "session"); - const bi = CATEGORY_ORDER.indexOf(b.category ?? "session"); - if (ai !== bi) { - return ai - bi; - } - // Exact prefix matches first - if (lower) { - const aExact = a.name.startsWith(lower) ? 0 : 1; - const bExact = b.name.startsWith(lower) ? 0 : 1; - if (aExact !== bExact) { - return aExact - bExact; - } - } - return 0; - }); -} - -export type ParsedSlashCommand = { - command: SlashCommandDef; - args: string; -}; - -/** - * Parse a message as a slash command. Returns null if it doesn't match. - * Supports `/command` and `/command args...`. - */ -export function parseSlashCommand(text: string): ParsedSlashCommand | null { - const trimmed = text.trim(); - if (!trimmed.startsWith("/")) { - return null; - } - - const spaceIdx = trimmed.indexOf(" "); - const name = spaceIdx === -1 ? trimmed.slice(1) : trimmed.slice(1, spaceIdx); - const args = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim(); - - if (!name) { - return null; - } - - const command = SLASH_COMMANDS.find((cmd) => cmd.name === name.toLowerCase()); - if (!command) { - return null; - } - - return { command, args }; -} diff --git a/ui/src/ui/chat/speech.ts b/ui/src/ui/chat/speech.ts deleted file mode 100644 index 4db4e6944a1..00000000000 --- a/ui/src/ui/chat/speech.ts +++ /dev/null @@ -1,225 +0,0 @@ -/** - * Browser-native speech services: STT via SpeechRecognition, TTS via SpeechSynthesis. - * Falls back gracefully when APIs are unavailable. - */ - -// ─── STT (Speech-to-Text) ─── - -type SpeechRecognitionEvent = Event & { - results: SpeechRecognitionResultList; - resultIndex: number; -}; - -type SpeechRecognitionErrorEvent = Event & { - error: string; - message?: string; -}; - -interface SpeechRecognitionInstance extends EventTarget { - continuous: boolean; - interimResults: boolean; - lang: string; - start(): void; - stop(): void; - abort(): void; - onresult: ((event: SpeechRecognitionEvent) => void) | null; - onerror: ((event: SpeechRecognitionErrorEvent) => void) | null; - onend: (() => void) | null; - onstart: (() => void) | null; -} - -type SpeechRecognitionCtor = new () => SpeechRecognitionInstance; - -function getSpeechRecognitionCtor(): SpeechRecognitionCtor | null { - const w = globalThis as Record; - return (w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null) as SpeechRecognitionCtor | null; -} - -export function isSttSupported(): boolean { - return getSpeechRecognitionCtor() !== null; -} - -export type SttCallbacks = { - onTranscript: (text: string, isFinal: boolean) => void; - onStart?: () => void; - onEnd?: () => void; - onError?: (error: string) => void; -}; - -let activeRecognition: SpeechRecognitionInstance | null = null; - -export function startStt(callbacks: SttCallbacks): boolean { - const Ctor = getSpeechRecognitionCtor(); - if (!Ctor) { - callbacks.onError?.("Speech recognition is not supported in this browser"); - return false; - } - - stopStt(); - - const recognition = new Ctor(); - recognition.continuous = true; - recognition.interimResults = true; - recognition.lang = navigator.language || "en-US"; - - recognition.addEventListener("start", () => callbacks.onStart?.()); - - recognition.addEventListener("result", (event) => { - const speechEvent = event as unknown as SpeechRecognitionEvent; - let interimTranscript = ""; - let finalTranscript = ""; - - for (let i = speechEvent.resultIndex; i < speechEvent.results.length; i++) { - const result = speechEvent.results[i]; - if (!result?.[0]) { - continue; - } - const transcript = result[0].transcript; - if (result.isFinal) { - finalTranscript += transcript; - } else { - interimTranscript += transcript; - } - } - - if (finalTranscript) { - callbacks.onTranscript(finalTranscript, true); - } else if (interimTranscript) { - callbacks.onTranscript(interimTranscript, false); - } - }); - - recognition.addEventListener("error", (event) => { - const speechEvent = event as unknown as SpeechRecognitionErrorEvent; - if (speechEvent.error === "aborted" || speechEvent.error === "no-speech") { - return; - } - callbacks.onError?.(speechEvent.error); - }); - - recognition.addEventListener("end", () => { - if (activeRecognition === recognition) { - activeRecognition = null; - } - callbacks.onEnd?.(); - }); - - activeRecognition = recognition; - recognition.start(); - return true; -} - -export function stopStt(): void { - if (activeRecognition) { - const r = activeRecognition; - activeRecognition = null; - try { - r.stop(); - } catch { - // already stopped - } - } -} - -export function isSttActive(): boolean { - return activeRecognition !== null; -} - -// ─── TTS (Text-to-Speech) ─── - -export function isTtsSupported(): boolean { - return "speechSynthesis" in globalThis; -} - -let currentUtterance: SpeechSynthesisUtterance | null = null; - -export function speakText( - text: string, - opts?: { - onStart?: () => void; - onEnd?: () => void; - onError?: (error: string) => void; - }, -): boolean { - if (!isTtsSupported()) { - opts?.onError?.("Speech synthesis is not supported in this browser"); - return false; - } - - stopTts(); - - const cleaned = stripMarkdown(text); - if (!cleaned.trim()) { - return false; - } - - const utterance = new SpeechSynthesisUtterance(cleaned); - utterance.rate = 1.0; - utterance.pitch = 1.0; - - utterance.addEventListener("start", () => opts?.onStart?.()); - utterance.addEventListener("end", () => { - if (currentUtterance === utterance) { - currentUtterance = null; - } - opts?.onEnd?.(); - }); - utterance.addEventListener("error", (e) => { - if (currentUtterance === utterance) { - currentUtterance = null; - } - if (e.error === "canceled" || e.error === "interrupted") { - return; - } - opts?.onError?.(e.error); - }); - - currentUtterance = utterance; - speechSynthesis.speak(utterance); - return true; -} - -export function stopTts(): void { - if (currentUtterance) { - currentUtterance = null; - } - if (isTtsSupported()) { - speechSynthesis.cancel(); - } -} - -export function isTtsSpeaking(): boolean { - return isTtsSupported() && speechSynthesis.speaking; -} - -/** Strip common markdown syntax for cleaner speech output. */ -function stripMarkdown(text: string): string { - return ( - text - // code blocks - .replace(/```[\s\S]*?```/g, "") - // inline code - .replace(/`[^`]+`/g, "") - // images - .replace(/!\[.*?\]\(.*?\)/g, "") - // links → keep text - .replace(/\[([^\]]+)\]\(.*?\)/g, "$1") - // headings - .replace(/^#{1,6}\s+/gm, "") - // bold/italic - .replace(/\*{1,3}(.*?)\*{1,3}/g, "$1") - .replace(/_{1,3}(.*?)_{1,3}/g, "$1") - // blockquotes - .replace(/^>\s?/gm, "") - // horizontal rules - .replace(/^[-*_]{3,}\s*$/gm, "") - // list markers - .replace(/^\s*[-*+]\s+/gm, "") - .replace(/^\s*\d+\.\s+/gm, "") - // HTML tags - .replace(/<[^>]+>/g, "") - // collapse whitespace - .replace(/\n{3,}/g, "\n\n") - .trim() - ); -}