diff --git a/CHANGELOG.md b/CHANGELOG.md index 437afa21343..0ccfb4b1af3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai - CLI/doctor plugins: lazy-load doctor plugin paths and prefer installed plugin `dist/*` runtime entries over source-adjacent JavaScript fallbacks, reducing the measured `doctor --non-interactive` runtime by about 74% while keeping cold doctor startup on built plugin artifacts. (#69840) Thanks @gumadeiras. - WhatsApp/groups+direct: forward per-group and per-direct `systemPrompt` config into inbound context `GroupSystemPrompt` so configured per-chat behavioral instructions are injected on every turn. Supports `"*"` wildcard fallback and account-scoped overrides under `channels.whatsapp.accounts..{groups,direct}`; account maps fully replace root maps (no deep merge), matching the existing `requireMention` pattern. Closes #7011. (#59553) Thanks @Bluetegu. - Plugins/startup: prefer native Jiti loading for built bundled plugin dist modules on supported runtimes, cutting measured bundled plugin load time by 82-90% while keeping source TypeScript on the transform path. (#69925) Thanks @aauren. +- TUI: add local embedded mode for running terminal chats without a Gateway while keeping plugin approval gates enforced. (#66767) Thanks @fuller-stack-dev. ### Fixes diff --git a/extensions/speech-core/src/tts.ts b/extensions/speech-core/src/tts.ts index bf5eda6bdf3..4804bcf489d 100644 --- a/extensions/speech-core/src/tts.ts +++ b/extensions/speech-core/src/tts.ts @@ -932,9 +932,19 @@ export async function textToSpeechTelephony(params: { logVerbose(`TTS telephony: provider ${provider} skipped (${resolvedProvider.message})`); continue; } - const synthesizeTelephony = resolvedProvider.provider.synthesizeTelephony as NonNullable< - typeof resolvedProvider.provider.synthesizeTelephony - >; + const synthesizeTelephony = resolvedProvider.provider.synthesizeTelephony; + if (!synthesizeTelephony) { + const message = `${provider}: unsupported for telephony`; + errors.push(message); + attempts.push({ + provider, + outcome: "skipped", + reasonCode: "unsupported_for_telephony", + error: message, + }); + logVerbose(`TTS telephony: provider ${provider} skipped (${message})`); + continue; + } const synthesis = await synthesizeTelephony({ text: params.text, cfg: params.cfg, diff --git a/src/agents/model-selection-display.test.ts b/src/agents/model-selection-display.test.ts index c6fa7d8c9b9..47dcfd8b3c2 100644 --- a/src/agents/model-selection-display.test.ts +++ b/src/agents/model-selection-display.test.ts @@ -88,5 +88,17 @@ describe("model-selection-display", () => { model: "anthropic/claude-haiku-4.5", }); }); + + it("falls back to configured defaults when runtime session state is empty", () => { + expect( + resolveSessionInfoModelSelection({ + defaultProvider: "openai", + defaultModel: "gpt-5.4", + }), + ).toEqual({ + modelProvider: "openai", + model: "gpt-5.4", + }); + }); }); }); diff --git a/src/agents/model-selection-display.ts b/src/agents/model-selection-display.ts index 0e2d508d0a9..c6a8a6d29b9 100644 --- a/src/agents/model-selection-display.ts +++ b/src/agents/model-selection-display.ts @@ -56,6 +56,8 @@ export function resolveModelDisplayName(params: ModelDisplaySelectionParams): st type SessionInfoModelSelectionParams = { currentProvider?: string | null; currentModel?: string | null; + defaultProvider?: string | null; + defaultModel?: string | null; entryProvider?: string | null; entryModel?: string | null; overrideProvider?: string | null; @@ -66,25 +68,27 @@ export function resolveSessionInfoModelSelection(params: SessionInfoModelSelecti modelProvider?: string; model?: string; } { + const fallbackProvider = params.currentProvider ?? params.defaultProvider ?? undefined; + const fallbackModel = params.currentModel ?? params.defaultModel ?? undefined; + if (params.entryProvider !== undefined || params.entryModel !== undefined) { return { - modelProvider: params.entryProvider ?? params.currentProvider ?? undefined, - model: params.entryModel ?? params.currentModel ?? undefined, + modelProvider: params.entryProvider ?? fallbackProvider, + model: params.entryModel ?? fallbackModel, }; } const overrideModel = params.overrideModel?.trim(); if (overrideModel) { const overrideProvider = params.overrideProvider?.trim(); - const currentProvider = params.currentProvider ?? undefined; return { - modelProvider: overrideProvider || currentProvider, + modelProvider: overrideProvider || fallbackProvider, model: overrideModel, }; } return { - modelProvider: params.currentProvider ?? undefined, - model: params.currentModel ?? undefined, + modelProvider: fallbackProvider, + model: fallbackModel, }; } diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index e511f5404fb..bdbc4b4143f 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { callGateway } from "../gateway/call.js"; +import { isEmbeddedMode } from "../infra/embedded-mode.js"; import { getActiveRuntimeWebToolsMetadata } from "../secrets/runtime.js"; import { normalizeDeliveryContext } from "../utils/delivery-context.js"; import type { GatewayMessageChannel } from "../utils/message-channel.js"; @@ -17,6 +18,7 @@ import { createAgentsListTool } from "./tools/agents-list-tool.js"; import { createCanvasTool } from "./tools/canvas-tool.js"; import type { AnyAgentTool } from "./tools/common.js"; import { createCronTool } from "./tools/cron-tool.js"; +import { createEmbeddedCallGateway } from "./tools/embedded-gateway-stub.js"; import { createGatewayTool } from "./tools/gateway-tool.js"; import { createImageGenerateTool } from "./tools/image-generate-tool.js"; import { createImageTool } from "./tools/image-tool.js"; @@ -227,22 +229,34 @@ export function createOpenClawTools( sandboxRoot: options?.sandboxRoot, workspaceDir, }); + const embedded = isEmbeddedMode(); + const effectiveCallGateway = embedded + ? createEmbeddedCallGateway() + : openClawToolsDeps.callGateway; const tools: AnyAgentTool[] = [ - createCanvasTool({ config: options?.config }), - nodesTool, - createCronTool({ - agentSessionKey: options?.agentSessionKey, - }), - ...(messageTool ? [messageTool] : []), + ...(embedded + ? [] + : [ + createCanvasTool({ config: options?.config }), + nodesTool, + createCronTool({ + agentSessionKey: options?.agentSessionKey, + }), + ]), + ...(!embedded && messageTool ? [messageTool] : []), createTtsTool({ agentChannel: options?.agentChannel, config: options?.config, }), ...collectPresentOpenClawTools([imageGenerateTool, musicGenerateTool, videoGenerateTool]), - createGatewayTool({ - agentSessionKey: options?.agentSessionKey, - config: options?.config, - }), + ...(embedded + ? [] + : [ + createGatewayTool({ + agentSessionKey: options?.agentSessionKey, + config: options?.config, + }), + ]), createAgentsListTool({ agentSessionKey: options?.agentSessionKey, requesterAgentIdOverride: options?.requesterAgentIdOverride, @@ -260,39 +274,43 @@ export function createOpenClawTools( agentSessionKey: options?.agentSessionKey, sandboxed: options?.sandboxed, config: resolvedConfig, - callGateway: openClawToolsDeps.callGateway, + callGateway: effectiveCallGateway, }), createSessionsHistoryTool({ agentSessionKey: options?.agentSessionKey, sandboxed: options?.sandboxed, config: resolvedConfig, - callGateway: openClawToolsDeps.callGateway, - }), - createSessionsSendTool({ - agentSessionKey: options?.agentSessionKey, - agentChannel: options?.agentChannel, - sandboxed: options?.sandboxed, - config: resolvedConfig, - callGateway: openClawToolsDeps.callGateway, + callGateway: effectiveCallGateway, }), + ...(embedded + ? [] + : [ + createSessionsSendTool({ + agentSessionKey: options?.agentSessionKey, + agentChannel: options?.agentChannel, + sandboxed: options?.sandboxed, + config: resolvedConfig, + callGateway: openClawToolsDeps.callGateway, + }), + createSessionsSpawnTool({ + agentSessionKey: options?.agentSessionKey, + agentChannel: options?.agentChannel, + agentAccountId: options?.agentAccountId, + agentTo: options?.agentTo, + agentThreadId: options?.agentThreadId, + agentGroupId: options?.agentGroupId, + agentGroupChannel: options?.agentGroupChannel, + agentGroupSpace: options?.agentGroupSpace, + agentMemberRoleIds: options?.agentMemberRoleIds, + sandboxed: options?.sandboxed, + requesterAgentIdOverride: options?.requesterAgentIdOverride, + workspaceDir: spawnWorkspaceDir, + }), + ]), createSessionsYieldTool({ sessionId: options?.sessionId, onYield: options?.onYield, }), - createSessionsSpawnTool({ - agentSessionKey: options?.agentSessionKey, - agentChannel: options?.agentChannel, - agentAccountId: options?.agentAccountId, - agentTo: options?.agentTo, - agentThreadId: options?.agentThreadId, - agentGroupId: options?.agentGroupId, - agentGroupChannel: options?.agentGroupChannel, - agentGroupSpace: options?.agentGroupSpace, - agentMemberRoleIds: options?.agentMemberRoleIds, - sandboxed: options?.sandboxed, - requesterAgentIdOverride: options?.requesterAgentIdOverride, - workspaceDir: spawnWorkspaceDir, - }), createSubagentsTool({ agentSessionKey: options?.agentSessionKey, }), diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 7c565734085..0e4bc64c375 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -9,6 +9,7 @@ import { } from "@mariozechner/pi-coding-agent"; import { filterHeartbeatPairs } from "../../../auto-reply/heartbeat-filter.js"; import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js"; +import { isEmbeddedMode } from "../../../infra/embedded-mode.js"; import { formatErrorMessage } from "../../../infra/errors.js"; import { resolveHeartbeatSummaryForAgent } from "../../../infra/heartbeat-summary.js"; import { getMachineDisplayName } from "../../../infra/machine-name.js"; @@ -619,11 +620,19 @@ export async function runEmbeddedAttempt( seenSignatures: params.bootstrapPromptWarningSignaturesSeen, previousSignature: params.bootstrapPromptWarningSignature, }); - const workspaceNotes = hookAdjustedBootstrapFiles.some( - (file) => file.name === DEFAULT_BOOTSTRAP_FILENAME && !file.missing, - ) - ? ["Reminder: commit your changes in this workspace after edits."] - : undefined; + const workspaceNotes: string[] = []; + if ( + hookAdjustedBootstrapFiles.some( + (file) => file.name === DEFAULT_BOOTSTRAP_FILENAME && !file.missing, + ) + ) { + workspaceNotes.push("Reminder: commit your changes in this workspace after edits."); + } + if (isEmbeddedMode()) { + workspaceNotes.push( + "Running in local embedded mode (no gateway). Most tools work locally. Gateway-dependent tools (canvas, nodes, cron, message, sessions_send, sessions_spawn, gateway) are unavailable. Subagent kill/steer require a gateway. Do not attempt to read gateway-specific files such as sessions.json, gateway.log, or gateway.pid.", + ); + } const { defaultAgentId } = resolveSessionAgentIds({ sessionKey: params.sessionKey, @@ -876,7 +885,7 @@ export async function runEmbeddedAttempt( skillsPrompt: effectiveSkillsPrompt, docsPath: docsPath ?? undefined, ttsHint, - workspaceNotes, + workspaceNotes: workspaceNotes?.length ? workspaceNotes : undefined, reactionGuidance, promptMode: effectivePromptMode, acpEnabled: params.config?.acp?.enabled !== false, diff --git a/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts b/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts index 05645c5732c..96ec1d0c09b 100644 --- a/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts +++ b/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts @@ -186,6 +186,30 @@ describe("handleAgentEnd", () => { }); }); + it("omits raw HTML auth bodies from consoleMessage for HTML 403 auth failures", async () => { + const ctx = createContext({ + role: "assistant", + stopReason: "error", + provider: "openai-codex", + model: "gpt-5.4", + errorMessage: "403 Access denied", + content: [{ type: "text", text: "" }], + }); + + await handleAgentEnd(ctx); + + const warnMeta = vi.mocked(ctx.log.warn).mock.calls[0]?.[1]; + expect(warnMeta).toMatchObject({ + providerRuntimeFailureKind: "auth_html_403", + rawErrorPreview: "403 Access denied", + error: + "Authentication failed with an HTML 403 response from the provider. Re-authenticate and verify your provider account access.", + }); + const consoleMsg = typeof warnMeta?.consoleMessage === "string" ? warnMeta.consoleMessage : ""; + expect(consoleMsg).not.toContain("rawError="); + expect(consoleMsg).not.toContain(""); + }); + it("keeps non-error run-end logging on debug only", async () => { const ctx = createContext(undefined); diff --git a/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts b/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts index 193df06b654..d9cf3173753 100644 --- a/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts +++ b/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts @@ -86,7 +86,14 @@ export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext): void | Promise< const safeModel = sanitizeForConsole(lastAssistant.model) ?? "unknown"; const safeProvider = sanitizeForConsole(lastAssistant.provider) ?? "unknown"; const safeRawErrorPreview = sanitizeForConsole(observedError.rawErrorPreview); - const rawErrorConsoleSuffix = safeRawErrorPreview ? ` rawError=${safeRawErrorPreview}` : ""; + const shouldSuppressRawErrorConsoleSuffix = + observedError.providerRuntimeFailureKind === "auth_html_403" || + observedError.providerRuntimeFailureKind === "auth_scope" || + observedError.providerRuntimeFailureKind === "auth_refresh"; + const rawErrorConsoleSuffix = + safeRawErrorPreview && !shouldSuppressRawErrorConsoleSuffix + ? ` rawError=${safeRawErrorPreview}` + : ""; ctx.log.warn("embedded run agent end", { event: "embedded_run_agent_end", tags: ["error_handling", "lifecycle", "agent_end", "assistant_error"], diff --git a/src/agents/pi-tools.before-tool-call.embedded-mode.test.ts b/src/agents/pi-tools.before-tool-call.embedded-mode.test.ts new file mode 100644 index 00000000000..36047c0a34d --- /dev/null +++ b/src/agents/pi-tools.before-tool-call.embedded-mode.test.ts @@ -0,0 +1,169 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { setEmbeddedMode } from "../infra/embedded-mode.js"; +import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; +import type { HookRunner } from "../plugins/hooks.js"; +import { PluginApprovalResolutions } from "../plugins/types.js"; +import { runBeforeToolCallHook } from "./pi-tools.before-tool-call.js"; +import { callGatewayTool } from "./tools/gateway.js"; + +vi.mock("../plugins/hook-runner-global.js", async () => { + const actual = await vi.importActual( + "../plugins/hook-runner-global.js", + ); + return { + ...actual, + getGlobalHookRunner: vi.fn(), + }; +}); +vi.mock("./tools/gateway.js", () => ({ + callGatewayTool: vi.fn(), +})); + +const mockGetGlobalHookRunner = vi.mocked(getGlobalHookRunner); +const mockCallGatewayTool = vi.mocked(callGatewayTool); + +describe("runBeforeToolCallHook — embedded mode approvals", () => { + let hookRunner: Pick; + let runBeforeToolCallMock: ReturnType>; + + beforeEach(() => { + runBeforeToolCallMock = vi.fn(); + hookRunner = { + hasHooks: vi.fn().mockReturnValue(true), + runBeforeToolCall: runBeforeToolCallMock, + }; + mockGetGlobalHookRunner.mockReturnValue(hookRunner as HookRunner); + mockCallGatewayTool.mockReset(); + }); + + afterEach(() => { + setEmbeddedMode(false); + }); + + it("blocks approval-required tools in embedded mode when no gateway approval route exists", async () => { + setEmbeddedMode(true); + const onResolution = vi.fn(); + + runBeforeToolCallMock.mockResolvedValue({ + requireApproval: { + pluginId: "test-plugin", + title: "Needs approval", + description: "Test approval request", + severity: "info", + onResolution, + }, + params: { adjusted: true }, + }); + mockCallGatewayTool.mockRejectedValueOnce(new Error("gateway unavailable")); + + const result = await runBeforeToolCallHook({ + toolName: "exec", + params: { command: "ls" }, + toolCallId: "call-1", + }); + + expect(result).toEqual({ + blocked: true, + reason: "Plugin approval required (gateway unavailable)", + }); + expect(mockCallGatewayTool).toHaveBeenCalledWith( + "plugin.approval.request", + expect.any(Object), + expect.any(Object), + expect.any(Object), + ); + expect(onResolution).toHaveBeenCalledTimes(1); + expect(onResolution).toHaveBeenCalledWith(PluginApprovalResolutions.CANCELLED); + }); + + it("sends approval to gateway when NOT in embedded mode", async () => { + setEmbeddedMode(false); + + runBeforeToolCallMock.mockResolvedValue({ + requireApproval: { + pluginId: "test-plugin", + title: "Needs approval", + description: "Test approval request", + severity: "info", + timeoutMs: 5_000, + }, + }); + + mockCallGatewayTool.mockResolvedValue({}); + + const result = await runBeforeToolCallHook({ + toolName: "exec", + params: { command: "ls" }, + toolCallId: "call-2", + }); + + expect(result.blocked).toBe(true); + expect(mockCallGatewayTool).toHaveBeenCalledWith( + "plugin.approval.request", + expect.any(Object), + expect.any(Object), + expect.any(Object), + ); + }); + + it("preserves hook params override after an approval allow decision", async () => { + setEmbeddedMode(true); + + runBeforeToolCallMock.mockResolvedValue({ + requireApproval: { + pluginId: "test-plugin", + title: "Approval", + description: "desc", + severity: "info", + }, + params: { extraField: "injected" }, + }); + mockCallGatewayTool.mockResolvedValueOnce({ + id: "approval-3", + decision: PluginApprovalResolutions.ALLOW_ONCE, + }); + + const result = await runBeforeToolCallHook({ + toolName: "write", + params: { path: "/tmp/test.txt", content: "hello" }, + toolCallId: "call-3", + }); + + expect(result.blocked).toBe(false); + if (!result.blocked) { + expect(result.params).toEqual({ + path: "/tmp/test.txt", + content: "hello", + extraField: "injected", + }); + } + }); + + it("keeps original params after an approval allow decision without overrides", async () => { + setEmbeddedMode(true); + + runBeforeToolCallMock.mockResolvedValue({ + requireApproval: { + pluginId: "test-plugin", + title: "Approval", + description: "desc", + severity: "info", + }, + }); + mockCallGatewayTool.mockResolvedValueOnce({ + id: "approval-4", + decision: PluginApprovalResolutions.ALLOW_ONCE, + }); + + const result = await runBeforeToolCallHook({ + toolName: "read", + params: { file: "/etc/hosts" }, + toolCallId: "call-4", + }); + + expect(result.blocked).toBe(false); + if (!result.blocked) { + expect(result.params).toEqual({ file: "/etc/hosts" }); + } + }); +}); diff --git a/src/agents/pi-tools.before-tool-call.ts b/src/agents/pi-tools.before-tool-call.ts index 6f27346c460..e29ff2c0814 100644 --- a/src/agents/pi-tools.before-tool-call.ts +++ b/src/agents/pi-tools.before-tool-call.ts @@ -350,7 +350,7 @@ export async function runBeforeToolCallHook(args: { reason: "Approval cancelled (run aborted)", }; } - log.warn(`plugin approval gateway request failed, falling back to block: ${String(err)}`); + log.warn(`plugin approval gateway request failed; blocking tool call: ${String(err)}`); return { blocked: true, reason: "Plugin approval required (gateway unavailable)", diff --git a/src/agents/tools/embedded-gateway-stub.runtime.ts b/src/agents/tools/embedded-gateway-stub.runtime.ts new file mode 100644 index 00000000000..cabc2348b09 --- /dev/null +++ b/src/agents/tools/embedded-gateway-stub.runtime.ts @@ -0,0 +1,23 @@ +export { resolveSessionAgentId } from "../../agents/agent-scope.js"; +export { loadConfig } from "../../config/config.js"; +export { stripEnvelopeFromMessages } from "../../gateway/chat-sanitize.js"; +export { augmentChatHistoryWithCliSessionImports } from "../../gateway/cli-session-history.js"; +export { getMaxChatHistoryMessagesBytes } from "../../gateway/server-constants.js"; +export { + augmentChatHistoryWithCanvasBlocks, + CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES, + enforceChatHistoryFinalBudget, + replaceOversizedChatHistoryMessages, + resolveEffectiveChatHistoryMaxChars, + sanitizeChatHistoryMessages, +} from "../../gateway/server-methods/chat.js"; +export { capArrayByJsonBytes } from "../../gateway/session-utils.fs.js"; +export { + listSessionsFromStore, + loadCombinedSessionStoreForGateway, + loadSessionEntry, + readSessionMessages, + resolveSessionModelRef, +} from "../../gateway/session-utils.js"; +export { resolveSessionKeyFromResolveParams } from "../../gateway/sessions-resolve.js"; +export type { SessionsListResult } from "../../gateway/session-utils.types.js"; diff --git a/src/agents/tools/embedded-gateway-stub.test.ts b/src/agents/tools/embedded-gateway-stub.test.ts new file mode 100644 index 00000000000..c8647faaf70 --- /dev/null +++ b/src/agents/tools/embedded-gateway-stub.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createEmbeddedCallGateway } from "./embedded-gateway-stub.js"; + +const runtime = vi.hoisted(() => ({ + loadConfig: vi.fn(() => ({ agents: { list: [{ id: "main", default: true }] } })), + resolveSessionKeyFromResolveParams: vi.fn(), +})); + +vi.mock("./embedded-gateway-stub.runtime.js", () => runtime); + +describe("embedded gateway stub", () => { + beforeEach(() => { + runtime.loadConfig.mockClear(); + runtime.resolveSessionKeyFromResolveParams.mockReset(); + }); + + it("resolves sessions through the gateway session resolver", async () => { + runtime.resolveSessionKeyFromResolveParams.mockResolvedValueOnce({ + ok: true, + key: "agent:main:main", + }); + + const callGateway = createEmbeddedCallGateway(); + const result = await callGateway<{ ok: true; key: string }>({ + method: "sessions.resolve", + params: { sessionId: "sess-main", includeGlobal: true }, + }); + + expect(result).toEqual({ ok: true, key: "agent:main:main" }); + expect(runtime.resolveSessionKeyFromResolveParams).toHaveBeenCalledWith({ + cfg: { agents: { list: [{ id: "main", default: true }] } }, + p: { sessionId: "sess-main", includeGlobal: true }, + }); + }); + + it("throws resolver errors for unresolved sessions", async () => { + runtime.resolveSessionKeyFromResolveParams.mockResolvedValueOnce({ + ok: false, + error: { message: "No session found: missing" }, + }); + + const callGateway = createEmbeddedCallGateway(); + + await expect( + callGateway({ + method: "sessions.resolve", + params: { key: "missing" }, + }), + ).rejects.toThrow("No session found: missing"); + }); +}); diff --git a/src/agents/tools/embedded-gateway-stub.ts b/src/agents/tools/embedded-gateway-stub.ts new file mode 100644 index 00000000000..b301c2b686f --- /dev/null +++ b/src/agents/tools/embedded-gateway-stub.ts @@ -0,0 +1,171 @@ +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { CallGatewayOptions } from "../../gateway/call.js"; +import type { SessionsListParams, SessionsResolveParams } from "../../gateway/protocol/index.js"; +import type { SessionsListResult } from "../../gateway/session-utils.types.js"; +import type { SessionsResolveResult } from "../../gateway/sessions-resolve.js"; + +type EmbeddedCallGateway = >(opts: CallGatewayOptions) => Promise; + +interface EmbeddedGatewayRuntime { + resolveSessionAgentId: (opts: { sessionKey: string; config: OpenClawConfig }) => string; + loadConfig: () => OpenClawConfig; + stripEnvelopeFromMessages: (msgs: unknown[]) => unknown[]; + augmentChatHistoryWithCliSessionImports: (opts: { + entry: unknown; + provider: string | undefined; + localMessages: unknown[]; + }) => unknown[]; + getMaxChatHistoryMessagesBytes: () => number; + augmentChatHistoryWithCanvasBlocks: (msgs: unknown[]) => unknown[]; + CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES: number; + enforceChatHistoryFinalBudget: (opts: { messages: unknown[]; maxBytes: number }) => { + messages: unknown[]; + }; + replaceOversizedChatHistoryMessages: (opts: { + messages: unknown[]; + maxSingleMessageBytes: number; + }) => { messages: unknown[] }; + resolveEffectiveChatHistoryMaxChars: (cfg: OpenClawConfig) => number; + sanitizeChatHistoryMessages: (msgs: unknown[], maxChars: number) => unknown[]; + capArrayByJsonBytes: (items: unknown[], maxBytes: number) => { items: unknown[] }; + listSessionsFromStore: (opts: { + cfg: OpenClawConfig; + storePath: string; + store: unknown; + opts: SessionsListParams; + }) => SessionsListResult; + loadCombinedSessionStoreForGateway: (cfg: OpenClawConfig) => { + storePath: string; + store: unknown; + }; + resolveSessionKeyFromResolveParams: (opts: { + cfg: OpenClawConfig; + p: SessionsResolveParams; + }) => Promise; + loadSessionEntry: (sessionKey: string) => { + cfg: OpenClawConfig; + storePath: string | undefined; + entry: Record | undefined; + }; + readSessionMessages: (sessionId: string, storePath: string, sessionFile?: string) => unknown[]; + resolveSessionModelRef: ( + cfg: OpenClawConfig, + entry: unknown, + sessionAgentId: string, + ) => { provider: string | undefined }; +} + +let runtimeMod: EmbeddedGatewayRuntime | undefined; + +async function getRuntime(): Promise { + if (!runtimeMod) { + const modPath = [".", "embedded-gateway-stub.runtime.js"].join("/"); + runtimeMod = (await import(modPath)) as EmbeddedGatewayRuntime; + } + return runtimeMod; +} + +async function handleSessionsList(params: Record) { + const rt = await getRuntime(); + const cfg = rt.loadConfig(); + const { storePath, store } = rt.loadCombinedSessionStoreForGateway(cfg); + return rt.listSessionsFromStore({ + cfg, + storePath, + store, + opts: params as SessionsListParams, + }); +} + +async function handleSessionsResolve(params: Record) { + const rt = await getRuntime(); + const cfg = rt.loadConfig(); + const resolved = await rt.resolveSessionKeyFromResolveParams({ + cfg, + p: params as SessionsResolveParams, + }); + if (!resolved.ok) { + throw new Error(resolved.error.message); + } + return { ok: true, key: resolved.key }; +} + +async function handleChatHistory(params: Record): Promise<{ + sessionKey: string; + sessionId: string | undefined; + messages: unknown[]; + thinkingLevel?: string; + fastMode?: boolean; + verboseLevel?: string; +}> { + const rt = await getRuntime(); + + const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey : ""; + const limit = typeof params.limit === "number" ? params.limit : undefined; + + const { cfg, storePath, entry } = rt.loadSessionEntry(sessionKey); + const sessionId = entry?.sessionId as string | undefined; + const sessionAgentId = rt.resolveSessionAgentId({ sessionKey, config: cfg }); + const resolvedSessionModel = rt.resolveSessionModelRef(cfg, entry, sessionAgentId); + + const localMessages = + sessionId && storePath + ? rt.readSessionMessages(sessionId, storePath, entry?.sessionFile as string | undefined) + : []; + + const rawMessages = rt.augmentChatHistoryWithCliSessionImports({ + entry, + provider: resolvedSessionModel.provider, + localMessages, + }); + + const hardMax = 1000; + const defaultLimit = 200; + const requested = typeof limit === "number" ? limit : defaultLimit; + const max = Math.min(hardMax, requested); + const effectiveMaxChars = rt.resolveEffectiveChatHistoryMaxChars(cfg); + + const sliced = rawMessages.length > max ? rawMessages.slice(-max) : rawMessages; + const sanitized = rt.stripEnvelopeFromMessages(sliced); + const normalized = rt.augmentChatHistoryWithCanvasBlocks( + rt.sanitizeChatHistoryMessages(sanitized, effectiveMaxChars), + ); + + const maxHistoryBytes = rt.getMaxChatHistoryMessagesBytes(); + const perMessageHardCap = Math.min(rt.CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES, maxHistoryBytes); + const replaced = rt.replaceOversizedChatHistoryMessages({ + messages: normalized, + maxSingleMessageBytes: perMessageHardCap, + }); + const capped = rt.capArrayByJsonBytes(replaced.messages, maxHistoryBytes).items; + const bounded = rt.enforceChatHistoryFinalBudget({ messages: capped, maxBytes: maxHistoryBytes }); + + return { + sessionKey, + sessionId, + messages: bounded.messages, + thinkingLevel: entry?.thinkingLevel as string | undefined, + fastMode: entry?.fastMode as boolean | undefined, + verboseLevel: entry?.verboseLevel as string | undefined, + }; +} + +export function createEmbeddedCallGateway(): EmbeddedCallGateway { + return async >(opts: CallGatewayOptions): Promise => { + const method = opts.method?.trim(); + const params = (opts.params ?? {}) as Record; + + switch (method) { + case "sessions.list": + return (await handleSessionsList(params)) as T; + case "sessions.resolve": + return (await handleSessionsResolve(params)) as T; + case "chat.history": + return (await handleChatHistory(params)) as T; + default: + throw new Error( + `Method "${method}" requires a running gateway (unavailable in local embedded mode).`, + ); + } + }; +} diff --git a/src/cli/program/register.subclis-core.ts b/src/cli/program/register.subclis-core.ts index 27ef05daa95..09cfe7e85cb 100644 --- a/src/cli/program/register.subclis-core.ts +++ b/src/cli/program/register.subclis-core.ts @@ -111,7 +111,7 @@ const entrySpecs: readonly CommandGroupDescriptorSpec[] = [ exportName: "registerSandboxCli", }, { - commandNames: ["tui"], + commandNames: ["tui", "terminal", "chat"], loadModule: () => import("../tui-cli.js"), exportName: "registerTuiCli", }, diff --git a/src/cli/program/subcli-descriptors.ts b/src/cli/program/subcli-descriptors.ts index 2060cfe78a4..615f484bd33 100644 --- a/src/cli/program/subcli-descriptors.ts +++ b/src/cli/program/subcli-descriptors.ts @@ -68,6 +68,16 @@ const subCliCommandCatalog = defineCommandDescriptorCatalog([ description: "Open a terminal UI connected to the Gateway", hasSubcommands: false, }, + { + name: "terminal", + description: "Open a local terminal UI (alias for tui --local)", + hasSubcommands: false, + }, + { + name: "chat", + description: "Open a local terminal UI (alias for tui --local)", + hasSubcommands: false, + }, { name: "cron", description: "Manage cron jobs via the Gateway scheduler", diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 9f864ce6934..56fb3183861 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -245,7 +245,10 @@ export async function runCli(argv: string[] = process.argv) { } const hasBuiltinPrimary = - primary !== null && program.commands.some((command) => command.name() === primary); + primary !== null && + program.commands.some( + (command) => command.name() === primary || command.aliases().includes(primary), + ); const shouldSkipPluginRegistration = shouldSkipPluginCommandRegistration({ argv: parseArgv, primary, @@ -264,7 +267,12 @@ export async function runCli(argv: string[] = process.argv) { }, ); if (config) { - if (primary && !program.commands.some((command) => command.name() === primary)) { + if ( + primary && + !program.commands.some( + (command) => command.name() === primary || command.aliases().includes(primary), + ) + ) { const missingPluginCommandMessage = resolveMissingPluginCommandMessage(primary, config); if (missingPluginCommandMessage) { throw new Error(missingPluginCommandMessage); diff --git a/src/cli/tui-cli.ts b/src/cli/tui-cli.ts index ad29a63a3e3..2f082d6a95a 100644 --- a/src/cli/tui-cli.ts +++ b/src/cli/tui-cli.ts @@ -8,7 +8,10 @@ import { parseTimeoutMs } from "./parse-timeout.js"; export function registerTuiCli(program: Command) { program .command("tui") + .alias("terminal") + .alias("chat") .description("Open a terminal UI connected to the Gateway") + .option("--local", "Run against the local embedded agent runtime", false) .option("--url ", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)") .option("--token ", "Gateway token (if required)") .option("--password ", "Gateway password (if required)") @@ -22,8 +25,17 @@ export function registerTuiCli(program: Command) { "after", () => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/tui", "docs.openclaw.ai/cli/tui")}\n`, ) - .action(async (opts) => { + .action(async (opts, cmd) => { try { + // `cmd.name()` always returns the canonical subcommand name (`tui`). + // Use the parsed parent args to see which alias the user actually typed. + const invokedSubcommand = cmd.parent?.args[0]; + const invokedAsLocalAlias = + invokedSubcommand === "terminal" || invokedSubcommand === "chat"; + const isLocal = Boolean(opts.local) || invokedAsLocalAlias; + if (isLocal && (opts.url || opts.token || opts.password)) { + throw new Error("--local cannot be combined with --url, --token, or --password"); + } const timeoutMs = parseTimeoutMs(opts.timeoutMs); if (opts.timeoutMs !== undefined && timeoutMs === undefined) { defaultRuntime.error( @@ -32,6 +44,7 @@ export function registerTuiCli(program: Command) { } const historyLimit = Number.parseInt(String(opts.historyLimit ?? "200"), 10); await runTui({ + local: isLocal, url: opts.url as string | undefined, token: opts.token as string | undefined, password: opts.password as string | undefined, diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index dc4e6a2319d..54437f88f40 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -146,7 +146,7 @@ async function buildWebchatAudioOnlyAssistantMessage( } export const DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS = 8_000; -const CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES = 128 * 1024; +export const CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES = 128 * 1024; const CHAT_HISTORY_OVERSIZED_PLACEHOLDER = "[chat.history omitted: message too large]"; let chatHistoryPlaceholderEmitCount = 0; const CHANNEL_AGNOSTIC_SESSION_SCOPES = new Set([ @@ -1133,7 +1133,7 @@ export function augmentChatHistoryWithCanvasBlocks(messages: unknown[]): unknown return changed ? next : messages; } -function buildOversizedHistoryPlaceholder(message?: unknown): Record { +export function buildOversizedHistoryPlaceholder(message?: unknown): Record { const role = message && typeof message === "object" && @@ -1154,7 +1154,7 @@ function buildOversizedHistoryPlaceholder(message?: unknown): Record 0 ? next : messages, replacedCount }; } -function enforceChatHistoryFinalBudget(params: { messages: unknown[]; maxBytes: number }): { +export function enforceChatHistoryFinalBudget(params: { messages: unknown[]; maxBytes: number }): { messages: unknown[]; placeholderCount: number; } { diff --git a/src/infra/embedded-mode.test.ts b/src/infra/embedded-mode.test.ts new file mode 100644 index 00000000000..9d06aaabb3d --- /dev/null +++ b/src/infra/embedded-mode.test.ts @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { isEmbeddedMode, setEmbeddedMode } from "./embedded-mode.js"; + +describe("embedded-mode flag", () => { + afterEach(() => { + setEmbeddedMode(false); + }); + + it("defaults to false", () => { + expect(isEmbeddedMode()).toBe(false); + }); + + it("can be set to true", () => { + setEmbeddedMode(true); + expect(isEmbeddedMode()).toBe(true); + }); + + it("can be toggled back to false", () => { + setEmbeddedMode(true); + expect(isEmbeddedMode()).toBe(true); + + setEmbeddedMode(false); + expect(isEmbeddedMode()).toBe(false); + }); +}); diff --git a/src/infra/embedded-mode.ts b/src/infra/embedded-mode.ts new file mode 100644 index 00000000000..a4d3e296a42 --- /dev/null +++ b/src/infra/embedded-mode.ts @@ -0,0 +1,9 @@ +let _embeddedMode = false; + +export function setEmbeddedMode(value: boolean): void { + _embeddedMode = value; +} + +export function isEmbeddedMode(): boolean { + return _embeddedMode; +} diff --git a/src/tui/commands.ts b/src/tui/commands.ts index bade73d3429..baa2e07ebd4 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -21,6 +21,7 @@ export type SlashCommandOptions = { cfg?: OpenClawConfig; provider?: string; model?: string; + local?: boolean; }; const COMMAND_ALIASES: Record = { @@ -66,6 +67,7 @@ export function getSlashCommands(options: SlashCommandOptions = {}): SlashComman { name: "help", description: "Show slash command help" }, { name: "gateway-status", description: "Show gateway status summary" }, { name: "gwstatus", description: "Alias for /gateway-status" }, + ...(options.local ? [{ name: "auth", description: "Run provider auth/login flow" }] : []), { name: "agent", description: "Switch agent (or open picker)" }, { name: "agents", description: "Open agent picker" }, { name: "session", description: "Switch session (or open picker)" }, @@ -157,6 +159,7 @@ export function helpText(options: SlashCommandOptions = {}): string { "/status", "/gateway-status", "/gwstatus", + ...(options.local ? ["/auth [provider]"] : []), "/agent (or /agents)", "/session (or /sessions)", "/model (or /models)", diff --git a/src/tui/embedded-backend.test.ts b/src/tui/embedded-backend.test.ts new file mode 100644 index 00000000000..b511fadcff0 --- /dev/null +++ b/src/tui/embedded-backend.test.ts @@ -0,0 +1,299 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { isEmbeddedMode, setEmbeddedMode } from "../infra/embedded-mode.js"; +import { defaultRuntime } from "../runtime.js"; + +const agentCommandFromIngressMock = vi.fn(); +let registeredListener: ((evt: unknown) => void) | undefined; + +vi.mock("../agents/agent-command.js", () => ({ + agentCommandFromIngress: (...args: unknown[]) => agentCommandFromIngressMock(...args), +})); + +vi.mock("../infra/agent-events.js", () => ({ + onAgentEvent: (listener: (evt: unknown) => void) => { + registeredListener = listener; + return () => { + if (registeredListener === listener) { + registeredListener = undefined; + } + }; + }, +})); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); +} + +describe("EmbeddedTuiBackend", () => { + const originalRuntimeLog = defaultRuntime.log; + const originalRuntimeError = defaultRuntime.error; + + beforeEach(() => { + agentCommandFromIngressMock.mockReset(); + registeredListener = undefined; + setEmbeddedMode(false); + defaultRuntime.log = originalRuntimeLog; + defaultRuntime.error = originalRuntimeError; + }); + + afterEach(() => { + setEmbeddedMode(false); + defaultRuntime.log = originalRuntimeLog; + defaultRuntime.error = originalRuntimeError; + }); + + it("bridges assistant and lifecycle events into chat events", async () => { + const { EmbeddedTuiBackend } = await import("./embedded-backend.js"); + const pending = deferred<{ + payloads: Array<{ text: string }>; + meta: Record; + }>(); + agentCommandFromIngressMock.mockReturnValueOnce(pending.promise); + + const backend = new EmbeddedTuiBackend(); + const events: Array<{ event: string; payload: unknown }> = []; + const onConnected = vi.fn(); + backend.onConnected = onConnected; + backend.onEvent = (evt) => { + events.push({ event: evt.event, payload: evt.payload }); + }; + + backend.start(); + await flushMicrotasks(); + expect(onConnected).toHaveBeenCalledTimes(1); + + await backend.sendChat({ + sessionKey: "agent:main:main", + message: "hello", + runId: "run-local-1", + }); + + registeredListener?.({ + runId: "run-local-1", + stream: "assistant", + data: { text: "hello", delta: "hello" }, + }); + registeredListener?.({ + runId: "run-local-1", + stream: "lifecycle", + data: { phase: "end", stopReason: "stop" }, + }); + + pending.resolve({ payloads: [{ text: "hello" }], meta: {} }); + await flushMicrotasks(); + + expect(events).toEqual([ + { + event: "agent", + payload: { + runId: "run-local-1", + stream: "assistant", + data: { text: "hello", delta: "hello" }, + }, + }, + { + event: "chat", + payload: { + runId: "run-local-1", + sessionKey: "agent:main:main", + state: "delta", + message: { + role: "assistant", + content: [{ type: "text", text: "hello" }], + timestamp: expect.any(Number), + }, + }, + }, + { + event: "agent", + payload: { + runId: "run-local-1", + stream: "lifecycle", + data: { phase: "end", stopReason: "stop" }, + }, + }, + { + event: "chat", + payload: { + runId: "run-local-1", + sessionKey: "agent:main:main", + state: "final", + stopReason: "stop", + message: { + role: "assistant", + content: [{ type: "text", text: "hello" }], + timestamp: expect.any(Number), + }, + }, + }, + ]); + }); + + it("emits side-result events for local /btw runs", async () => { + const { EmbeddedTuiBackend } = await import("./embedded-backend.js"); + agentCommandFromIngressMock.mockResolvedValueOnce({ + payloads: [{ text: "nothing important" }], + meta: {}, + }); + + const backend = new EmbeddedTuiBackend(); + const events: Array<{ event: string; payload: unknown }> = []; + backend.onEvent = (evt) => { + events.push({ event: evt.event, payload: evt.payload }); + }; + + backend.start(); + await backend.sendChat({ + sessionKey: "agent:main:main", + message: "/btw what changed?", + runId: "run-btw-1", + }); + await flushMicrotasks(); + + expect(events).toEqual([ + { + event: "chat.side_result", + payload: { + kind: "btw", + runId: "run-btw-1", + sessionKey: "agent:main:main", + question: "what changed?", + text: "nothing important", + }, + }, + { + event: "chat", + payload: { + runId: "run-btw-1", + sessionKey: "agent:main:main", + state: "final", + }, + }, + ]); + }); + + it("registers tool-first local runs before forwarding agent events", async () => { + const { EmbeddedTuiBackend } = await import("./embedded-backend.js"); + const pending = deferred<{ + payloads: Array<{ text: string }>; + meta: Record; + }>(); + agentCommandFromIngressMock.mockReturnValueOnce(pending.promise); + + const backend = new EmbeddedTuiBackend(); + const events: Array<{ event: string; payload: unknown }> = []; + backend.onEvent = (evt) => { + events.push({ event: evt.event, payload: evt.payload }); + }; + + backend.start(); + await backend.sendChat({ + sessionKey: "agent:main:main", + message: "run tool first", + runId: "run-tool-first", + }); + + registeredListener?.({ + runId: "run-tool-first", + stream: "tool", + data: { phase: "start", toolCallId: "tc-tool-first", name: "exec" }, + }); + pending.resolve({ payloads: [{ text: "done" }], meta: {} }); + await flushMicrotasks(); + + expect(events).toEqual([ + { + event: "chat", + payload: { + runId: "run-tool-first", + sessionKey: "agent:main:main", + state: "delta", + message: { + role: "assistant", + content: [{ type: "text", text: "" }], + timestamp: expect.any(Number), + }, + }, + }, + { + event: "agent", + payload: { + runId: "run-tool-first", + stream: "tool", + data: { phase: "start", toolCallId: "tc-tool-first", name: "exec" }, + }, + }, + { + event: "chat", + payload: { + runId: "run-tool-first", + sessionKey: "agent:main:main", + state: "final", + message: { + role: "assistant", + content: [{ type: "text", text: "done" }], + timestamp: expect.any(Number), + }, + }, + }, + ]); + }); + + it("aborts active local runs", async () => { + const { EmbeddedTuiBackend } = await import("./embedded-backend.js"); + let capturedSignal: AbortSignal | undefined; + agentCommandFromIngressMock.mockImplementationOnce((opts: { abortSignal?: AbortSignal }) => { + capturedSignal = opts.abortSignal; + return new Promise((_, reject) => { + opts.abortSignal?.addEventListener("abort", () => reject(new Error("aborted")), { + once: true, + }); + }); + }); + + const backend = new EmbeddedTuiBackend(); + backend.start(); + await backend.sendChat({ + sessionKey: "agent:main:main", + message: "long task", + runId: "run-abort-1", + }); + + const result = await backend.abortChat({ + sessionKey: "agent:main:main", + runId: "run-abort-1", + }); + await flushMicrotasks(); + + expect(result).toEqual({ ok: true, aborted: true }); + expect(capturedSignal?.aborted).toBe(true); + }); + + it("restores embedded mode and runtime loggers on stop", async () => { + const { EmbeddedTuiBackend } = await import("./embedded-backend.js"); + + const backend = new EmbeddedTuiBackend(); + backend.start(); + + expect(isEmbeddedMode()).toBe(true); + expect(defaultRuntime.log).not.toBe(originalRuntimeLog); + expect(defaultRuntime.error).not.toBe(originalRuntimeError); + + backend.stop(); + + expect(isEmbeddedMode()).toBe(false); + expect(defaultRuntime.log).toBe(originalRuntimeLog); + expect(defaultRuntime.error).toBe(originalRuntimeError); + }); +}); diff --git a/src/tui/embedded-backend.ts b/src/tui/embedded-backend.ts new file mode 100644 index 00000000000..69ac2c1f5ca --- /dev/null +++ b/src/tui/embedded-backend.ts @@ -0,0 +1,625 @@ +import { randomUUID } from "node:crypto"; +import { agentCommandFromIngress } from "../agents/agent-command.js"; +import { resolveSessionAgentId } from "../agents/agent-scope.js"; +import { DEFAULT_PROVIDER } from "../agents/defaults.js"; +import { buildAllowedModelSet, resolveThinkingDefault } from "../agents/model-selection.js"; +import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; +import { createDefaultDeps } from "../cli/deps.js"; +import { loadConfig } from "../config/config.js"; +import { updateSessionStore } from "../config/sessions.js"; +import { stripEnvelopeFromMessages } from "../gateway/chat-sanitize.js"; +import { augmentChatHistoryWithCliSessionImports } from "../gateway/cli-session-history.js"; +import type { SessionsPatchResult } from "../gateway/protocol/index.js"; +import { getMaxChatHistoryMessagesBytes } from "../gateway/server-constants.js"; +import { + injectTimestamp, + timestampOptsFromConfig, +} from "../gateway/server-methods/agent-timestamp.js"; +import { + augmentChatHistoryWithCanvasBlocks, + CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES, + enforceChatHistoryFinalBudget, + replaceOversizedChatHistoryMessages, + resolveEffectiveChatHistoryMaxChars, + sanitizeChatHistoryMessages, +} from "../gateway/server-methods/chat.js"; +import { loadGatewayModelCatalog } from "../gateway/server-model-catalog.js"; +import { performGatewaySessionReset } from "../gateway/session-reset-service.js"; +import { capArrayByJsonBytes } from "../gateway/session-utils.fs.js"; +import { + listAgentsForGateway, + listSessionsFromStore, + loadCombinedSessionStoreForGateway, + loadSessionEntry, + migrateAndPruneGatewaySessionStoreKey, + resolveGatewaySessionStoreTarget, + resolveSessionModelRef, + readSessionMessages, +} from "../gateway/session-utils.js"; +import { applySessionsPatchToStore } from "../gateway/sessions-patch.js"; +import { type AgentEventPayload, onAgentEvent } from "../infra/agent-events.js"; +import { setEmbeddedMode } from "../infra/embedded-mode.js"; +import { defaultRuntime } from "../runtime.js"; +import { stripInlineDirectiveTagsForDisplay } from "../utils/directive-tags.js"; +import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js"; +import type { + ChatSendOptions, + TuiAgentsList, + TuiBackend, + TuiEvent, + TuiModelChoice, + TuiSessionList, +} from "./tui-backend.js"; + +type LocalRunState = { + sessionKey: string; + controller: AbortController; + buffer: string; + isBtw: boolean; + question?: string; + finalSent: boolean; + registered: boolean; +}; + +const silentRuntime = { + log: (..._args: unknown[]) => undefined, + error: (..._args: unknown[]) => undefined, + exit: (code: number): never => { + throw new Error(`embedded tui runtime exit ${String(code)}`); + }, +}; + +function isSilentReplyLeadFragment(text: string): boolean { + const normalized = text.trim().toUpperCase(); + if (!normalized) { + return false; + } + if (!/^[A-Z_]+$/.test(normalized)) { + return false; + } + if (normalized === SILENT_REPLY_TOKEN) { + return false; + } + return SILENT_REPLY_TOKEN.startsWith(normalized); +} + +function appendUniqueSuffix(base: string, suffix: string): string { + if (!suffix) { + return base; + } + if (!base) { + return suffix; + } + if (base.endsWith(suffix)) { + return base; + } + const maxOverlap = Math.min(base.length, suffix.length); + for (let overlap = maxOverlap; overlap > 0; overlap -= 1) { + if (base.slice(-overlap) === suffix.slice(0, overlap)) { + return base + suffix.slice(overlap); + } + } + return base + suffix; +} + +function resolveMergedAssistantText(params: { + previousText: string; + nextText: string; + nextDelta: string; +}): string { + const previous = params.previousText; + const next = params.nextText; + const delta = params.nextDelta; + if (!previous) { + return next || delta; + } + if (next && next.startsWith(previous)) { + return next; + } + if (delta) { + return appendUniqueSuffix(previous, delta); + } + return appendUniqueSuffix(previous, next); +} + +function resolveBtwQuestion(message: string): string | undefined { + const match = /^\/btw(?::|\s)+(.*)$/i.exec(message.trim()); + const question = match?.[1]?.trim(); + return question ? question : undefined; +} + +function payloadText(parts: unknown): string { + if (!Array.isArray(parts)) { + return ""; + } + return parts + .map((part) => { + if (!part || typeof part !== "object") { + return ""; + } + const payload = part as { text?: unknown }; + return typeof payload.text === "string" ? payload.text.trim() : ""; + }) + .filter(Boolean) + .join("\n\n") + .trim(); +} + +function timeoutSecondsFromMs(timeoutMs?: number): string | undefined { + if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs < 0) { + return undefined; + } + return String(Math.max(0, Math.ceil(timeoutMs / 1000))); +} + +export class EmbeddedTuiBackend implements TuiBackend { + readonly connection = { url: "local embedded" }; + + onEvent?: (evt: TuiEvent) => void; + onConnected?: () => void; + onDisconnected?: (reason: string) => void; + onGap?: (info: { expected: number; received: number }) => void; + + private readonly deps = createDefaultDeps(); + private readonly runs = new Map(); + private unsubscribe?: () => void; + private previousRuntimeLog?: typeof defaultRuntime.log; + private previousRuntimeError?: typeof defaultRuntime.error; + private seq = 0; + + start() { + if (this.unsubscribe) { + return; + } + setEmbeddedMode(true); + // Suppress console output from logError/logInfo that would pollute the TUI. + // File logger (getLogger()) still captures everything via logger.ts:35. + this.previousRuntimeLog = defaultRuntime.log; + this.previousRuntimeError = defaultRuntime.error; + defaultRuntime.log = silentRuntime.log; + defaultRuntime.error = silentRuntime.error; + this.unsubscribe = onAgentEvent((evt) => { + void this.handleAgentEvent(evt); + }); + queueMicrotask(() => { + this.onConnected?.(); + }); + } + + stop() { + this.unsubscribe?.(); + this.unsubscribe = undefined; + for (const run of this.runs.values()) { + run.controller.abort(); + } + this.runs.clear(); + defaultRuntime.log = this.previousRuntimeLog ?? defaultRuntime.log; + defaultRuntime.error = this.previousRuntimeError ?? defaultRuntime.error; + this.previousRuntimeLog = undefined; + this.previousRuntimeError = undefined; + setEmbeddedMode(false); + } + + async sendChat(opts: ChatSendOptions): Promise<{ runId: string }> { + const runId = opts.runId ?? randomUUID(); + const question = resolveBtwQuestion(opts.message); + if (!question) { + this.abortSessionRuns(opts.sessionKey); + } + const controller = new AbortController(); + this.runs.set(runId, { + sessionKey: opts.sessionKey, + controller, + buffer: "", + isBtw: Boolean(question), + question, + finalSent: false, + registered: false, + }); + + void this.runTurn({ + runId, + sessionKey: opts.sessionKey, + message: opts.message, + thinking: opts.thinking, + deliver: opts.deliver, + timeoutMs: opts.timeoutMs, + controller, + }); + + return { runId }; + } + + async abortChat(opts: { sessionKey: string; runId: string }) { + const run = this.runs.get(opts.runId); + if (!run || run.sessionKey !== opts.sessionKey) { + return { ok: true, aborted: false }; + } + run.controller.abort(); + return { ok: true, aborted: true }; + } + + async loadHistory(opts: { sessionKey: string; limit?: number }) { + const { cfg, storePath, entry } = loadSessionEntry(opts.sessionKey); + const sessionId = entry?.sessionId; + const sessionAgentId = resolveSessionAgentId({ sessionKey: opts.sessionKey, config: cfg }); + const resolvedSessionModel = resolveSessionModelRef(cfg, entry, sessionAgentId); + const localMessages = + sessionId && storePath ? readSessionMessages(sessionId, storePath, entry?.sessionFile) : []; + const rawMessages = augmentChatHistoryWithCliSessionImports({ + entry, + provider: resolvedSessionModel.provider, + localMessages, + }); + const max = Math.min(1000, typeof opts.limit === "number" ? opts.limit : 200); + const sliced = rawMessages.length > max ? rawMessages.slice(-max) : rawMessages; + const effectiveMaxChars = resolveEffectiveChatHistoryMaxChars(cfg); + const sanitized = stripEnvelopeFromMessages(sliced); + const normalized = augmentChatHistoryWithCanvasBlocks( + sanitizeChatHistoryMessages(sanitized, effectiveMaxChars), + ); + const maxHistoryBytes = getMaxChatHistoryMessagesBytes(); + const perMessageHardCap = Math.min(CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES, maxHistoryBytes); + const replaced = replaceOversizedChatHistoryMessages({ + messages: normalized, + maxSingleMessageBytes: perMessageHardCap, + }); + const capped = capArrayByJsonBytes(replaced.messages, maxHistoryBytes).items; + const bounded = enforceChatHistoryFinalBudget({ messages: capped, maxBytes: maxHistoryBytes }); + const messages = bounded.messages; + + let thinkingLevel = entry?.thinkingLevel; + if (!thinkingLevel) { + const catalog = await loadGatewayModelCatalog(); + thinkingLevel = resolveThinkingDefault({ + cfg, + provider: resolvedSessionModel.provider, + model: resolvedSessionModel.model, + catalog, + }); + } + + return { + sessionKey: opts.sessionKey, + sessionId, + messages, + thinkingLevel, + fastMode: entry?.fastMode, + verboseLevel: entry?.verboseLevel ?? cfg.agents?.defaults?.verboseDefault, + }; + } + + async listSessions(opts?: Parameters[0]): Promise { + const cfg = loadConfig(); + const { storePath, store } = loadCombinedSessionStoreForGateway(cfg); + return listSessionsFromStore({ + cfg, + storePath, + store, + opts: opts ?? {}, + }) as TuiSessionList; + } + + async listAgents(): Promise { + return listAgentsForGateway(loadConfig()) as TuiAgentsList; + } + + async patchSession( + opts: Parameters[0], + ): Promise { + const cfg = loadConfig(); + const target = resolveGatewaySessionStoreTarget({ cfg, key: opts.key }); + const applied = await updateSessionStore(target.storePath, async (store) => { + const { primaryKey } = migrateAndPruneGatewaySessionStoreKey({ + cfg, + key: opts.key, + store, + }); + return await applySessionsPatchToStore({ + cfg, + store, + storeKey: primaryKey, + patch: opts, + loadGatewayModelCatalog, + }); + }); + if (!applied.ok) { + throw new Error(applied.error.message); + } + + const agentId = resolveSessionAgentId({ + sessionKey: target.canonicalKey ?? opts.key, + config: cfg, + }); + const resolved = resolveSessionModelRef(cfg, applied.entry, agentId); + return { + ok: true as const, + path: target.storePath, + key: target.canonicalKey ?? opts.key, + entry: applied.entry, + resolved: { + modelProvider: resolved.provider, + model: resolved.model, + }, + }; + } + + async resetSession(key: string, reason?: "new" | "reset") { + const result = await performGatewaySessionReset({ + key, + reason: reason === "new" ? "new" : "reset", + commandSource: "tui:embedded", + }); + if (!result.ok) { + throw new Error(result.error.message); + } + return { ok: true, key: result.key, entry: result.entry }; + } + + async getGatewayStatus() { + return `local embedded mode${this.runs.size > 0 ? ` (${String(this.runs.size)} active run${this.runs.size === 1 ? "" : "s"})` : ""}`; + } + + async listModels(): Promise { + const catalog = await loadGatewayModelCatalog(); + const cfg = loadConfig(); + const { allowedCatalog } = buildAllowedModelSet({ + cfg, + catalog, + defaultProvider: DEFAULT_PROVIDER, + }); + const entries = allowedCatalog.length > 0 ? allowedCatalog : catalog; + return entries.map((entry) => ({ + id: entry.id, + name: entry.name ?? entry.id, + provider: entry.provider, + contextWindow: entry.contextWindow, + reasoning: entry.reasoning, + })); + } + + private abortSessionRuns(sessionKey: string) { + for (const run of this.runs.values()) { + if (run.sessionKey === sessionKey && !run.isBtw) { + run.controller.abort(); + } + } + } + + private nextSeq() { + this.seq += 1; + return this.seq; + } + + private emit(event: string, payload: unknown) { + this.onEvent?.({ + event, + payload, + seq: this.nextSeq(), + }); + } + + private emitChatDelta(runId: string, run: LocalRunState) { + const text = run.buffer.trim(); + if (!text || isSilentReplyText(text, SILENT_REPLY_TOKEN) || isSilentReplyLeadFragment(text)) { + return; + } + run.registered = true; + this.emit("chat", { + runId, + sessionKey: run.sessionKey, + state: "delta", + message: { + role: "assistant", + content: [{ type: "text", text }], + timestamp: Date.now(), + }, + }); + } + + private emitChatFinal(runId: string, run: LocalRunState, stopReason?: string) { + if (run.finalSent) { + return; + } + run.finalSent = true; + run.registered = true; + const text = run.buffer.trim(); + const shouldIncludeMessage = + Boolean(text) && + !isSilentReplyText(text, SILENT_REPLY_TOKEN) && + !isSilentReplyLeadFragment(text); + this.emit("chat", { + runId, + sessionKey: run.sessionKey, + state: "final", + ...(stopReason ? { stopReason } : {}), + ...(shouldIncludeMessage + ? { + message: { + role: "assistant", + content: [{ type: "text", text }], + timestamp: Date.now(), + }, + } + : {}), + }); + } + + private emitChatAborted(runId: string, run: LocalRunState) { + if (run.finalSent) { + return; + } + run.finalSent = true; + run.registered = true; + this.emit("chat", { + runId, + sessionKey: run.sessionKey, + state: "aborted", + }); + } + + private emitChatError(runId: string, run: LocalRunState, errorMessage?: string) { + if (run.finalSent) { + return; + } + run.finalSent = true; + run.registered = true; + this.emit("chat", { + runId, + sessionKey: run.sessionKey, + state: "error", + ...(errorMessage ? { errorMessage } : {}), + }); + } + + private ensureRunRegistered(runId: string, run: LocalRunState) { + if (run.registered || run.isBtw) { + return; + } + run.registered = true; + this.emit("chat", { + runId, + sessionKey: run.sessionKey, + state: "delta", + message: { + role: "assistant", + content: [{ type: "text", text: "" }], + timestamp: Date.now(), + }, + }); + } + + private async handleAgentEvent(evt: AgentEventPayload) { + const run = this.runs.get(evt.runId); + if (!run) { + return; + } + + if (evt.stream !== "assistant") { + this.ensureRunRegistered(evt.runId, run); + } + + this.emit("agent", { + runId: evt.runId, + stream: evt.stream, + data: evt.data, + }); + + if (evt.stream === "assistant" && !run.isBtw && typeof evt.data?.text === "string") { + const nextText = stripInlineDirectiveTagsForDisplay(evt.data.text).text; + const nextDelta = + typeof evt.data?.delta === "string" + ? stripInlineDirectiveTagsForDisplay(evt.data.delta).text + : ""; + run.buffer = resolveMergedAssistantText({ + previousText: run.buffer, + nextText, + nextDelta, + }); + this.emitChatDelta(evt.runId, run); + return; + } + + if (evt.stream !== "lifecycle") { + return; + } + + const phase = typeof evt.data?.phase === "string" ? evt.data.phase : ""; + const aborted = evt.data?.aborted === true || run.controller.signal.aborted; + if (phase === "end") { + if (aborted) { + this.emitChatAborted(evt.runId, run); + return; + } + if (!run.isBtw) { + const stopReason = + typeof evt.data?.stopReason === "string" ? evt.data.stopReason : undefined; + this.emitChatFinal(evt.runId, run, stopReason); + } + return; + } + + if (phase === "error") { + if (aborted) { + this.emitChatAborted(evt.runId, run); + return; + } + const errorMessage = typeof evt.data?.error === "string" ? evt.data.error : undefined; + this.emitChatError(evt.runId, run, errorMessage); + } + } + + private async runTurn(params: { + runId: string; + sessionKey: string; + message: string; + thinking?: string; + deliver?: boolean; + timeoutMs?: number; + controller: AbortController; + }) { + try { + const { cfg, canonicalKey, entry } = loadSessionEntry(params.sessionKey); + const result = await agentCommandFromIngress( + { + message: injectTimestamp(params.message, timestampOptsFromConfig(cfg)), + sessionKey: canonicalKey, + ...(entry?.sessionId ? { sessionId: entry.sessionId } : {}), + thinking: params.thinking, + deliver: params.deliver, + channel: INTERNAL_MESSAGE_CHANNEL, + runContext: { + messageChannel: INTERNAL_MESSAGE_CHANNEL, + }, + timeout: timeoutSecondsFromMs(params.timeoutMs), + runId: params.runId, + abortSignal: params.controller.signal, + senderIsOwner: true, + allowModelOverride: false, + }, + silentRuntime, + this.deps, + ); + const run = this.runs.get(params.runId); + if (!run) { + return; + } + + if (run.isBtw) { + const text = payloadText(result?.payloads); + if (run.question && text) { + this.emit("chat.side_result", { + kind: "btw", + runId: params.runId, + sessionKey: run.sessionKey, + question: run.question, + text, + }); + } + this.emitChatFinal(params.runId, run); + return; + } + + if (!run.finalSent) { + const normalizedText = payloadText(result?.payloads); + if (normalizedText && !run.buffer) { + run.buffer = normalizedText; + } + this.emitChatFinal(params.runId, run); + } + } catch (error) { + const run = this.runs.get(params.runId); + if (!run) { + return; + } + if (params.controller.signal.aborted) { + this.emitChatAborted(params.runId, run); + return; + } + const errorMessage = error instanceof Error ? error.message : String(error); + this.emitChatError(params.runId, run, errorMessage); + } finally { + this.runs.delete(params.runId); + } + } +} diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index e489a28b92f..61ea951824d 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -24,7 +24,14 @@ import { import { formatErrorMessage } from "../infra/errors.js"; import { VERSION } from "../version.js"; import { TUI_SETUP_AUTH_SOURCE_CONFIG, TUI_SETUP_AUTH_SOURCE_ENV } from "./setup-launch-env.js"; -import type { ResponseUsageMode, SessionInfo, SessionScope } from "./tui-types.js"; +import type { + ChatSendOptions, + TuiAgentsList, + TuiBackend, + TuiEvent, + TuiModelChoice, + TuiSessionList, +} from "./tui-backend.js"; export type GatewayConnectionOptions = { url?: string; @@ -32,20 +39,7 @@ export type GatewayConnectionOptions = { password?: string; }; -export type ChatSendOptions = { - sessionKey: string; - message: string; - thinking?: string; - deliver?: boolean; - timeoutMs?: number; - runId?: string; -}; - -export type GatewayEvent = { - event: string; - payload?: unknown; - seq?: number; -}; +export type GatewayEvent = TuiEvent; const STARTUP_CHAT_HISTORY_RETRY_TIMEOUT_MS = 60_000; const STARTUP_CHAT_HISTORY_DEFAULT_RETRY_MS = 500; @@ -96,70 +90,11 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -export type GatewaySessionList = { - ts: number; - path: string; - count: number; - defaults?: { - model?: string | null; - modelProvider?: string | null; - contextTokens?: number | null; - }; - sessions: Array< - Pick< - SessionInfo, - | "thinkingLevel" - | "fastMode" - | "verboseLevel" - | "reasoningLevel" - | "model" - | "contextTokens" - | "inputTokens" - | "outputTokens" - | "totalTokens" - | "modelProvider" - | "displayName" - > & { - key: string; - sessionId?: string; - updatedAt?: number | null; - fastMode?: boolean; - sendPolicy?: string; - responseUsage?: ResponseUsageMode; - label?: string; - provider?: string; - groupChannel?: string; - space?: string; - subject?: string; - chatType?: string; - lastProvider?: string; - lastTo?: string; - lastAccountId?: string; - derivedTitle?: string; - lastMessagePreview?: string; - } - >; -}; +export type GatewaySessionList = TuiSessionList; +export type GatewayAgentsList = TuiAgentsList; +export type GatewayModelChoice = TuiModelChoice; -export type GatewayAgentsList = { - defaultId: string; - mainKey: string; - scope: SessionScope; - agents: Array<{ - id: string; - name?: string; - }>; -}; - -export type GatewayModelChoice = { - id: string; - name: string; - provider: string; - contextWindow?: number; - reasoning?: boolean; -}; - -export class GatewayChatClient { +export class GatewayChatClient implements TuiBackend { private client: GatewayClient; private readyPromise: Promise; private resolveReady?: () => void; diff --git a/src/tui/tui-backend.ts b/src/tui/tui-backend.ts new file mode 100644 index 00000000000..5e52492f3c3 --- /dev/null +++ b/src/tui/tui-backend.ts @@ -0,0 +1,110 @@ +import type { + SessionsListParams, + SessionsPatchParams, + SessionsPatchResult, +} from "../gateway/protocol/index.js"; +import type { ResponseUsageMode, SessionInfo, SessionScope } from "./tui-types.js"; + +export type ChatSendOptions = { + sessionKey: string; + message: string; + thinking?: string; + deliver?: boolean; + timeoutMs?: number; + runId?: string; +}; + +export type TuiEvent = { + event: string; + payload?: unknown; + seq?: number; +}; + +export type TuiSessionList = { + ts: number; + path: string; + count: number; + defaults?: { + model?: string | null; + modelProvider?: string | null; + contextTokens?: number | null; + }; + sessions: Array< + Pick< + SessionInfo, + | "thinkingLevel" + | "fastMode" + | "verboseLevel" + | "reasoningLevel" + | "model" + | "contextTokens" + | "inputTokens" + | "outputTokens" + | "totalTokens" + | "modelProvider" + | "displayName" + > & { + key: string; + sessionId?: string; + updatedAt?: number | null; + fastMode?: boolean; + sendPolicy?: string; + responseUsage?: ResponseUsageMode; + label?: string; + provider?: string; + groupChannel?: string; + space?: string; + subject?: string; + chatType?: string; + lastProvider?: string; + lastTo?: string; + lastAccountId?: string; + derivedTitle?: string; + lastMessagePreview?: string; + } + >; +}; + +export type TuiAgentsList = { + defaultId: string; + mainKey: string; + scope: SessionScope; + agents: Array<{ + id: string; + name?: string; + }>; +}; + +export type TuiModelChoice = { + id: string; + name: string; + provider: string; + contextWindow?: number; + reasoning?: boolean; +}; + +export type TuiBackend = { + connection: { + url: string; + token?: string; + password?: string; + }; + onEvent?: (evt: TuiEvent) => void; + onConnected?: () => void; + onDisconnected?: (reason: string) => void; + onGap?: (info: { expected: number; received: number }) => void; + start: () => void; + stop: () => void; + sendChat: (opts: ChatSendOptions) => Promise<{ runId: string }>; + abortChat: (opts: { + sessionKey: string; + runId: string; + }) => Promise<{ ok: boolean; aborted: boolean }>; + loadHistory: (opts: { sessionKey: string; limit?: number }) => Promise; + listSessions: (opts?: SessionsListParams) => Promise; + listAgents: () => Promise; + patchSession: (opts: SessionsPatchParams) => Promise; + resetSession: (key: string, reason?: "new" | "reset") => Promise; + getGatewayStatus: () => Promise; + listModels: () => Promise; +}; diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts index ce2f34a4d37..2f56d81b64c 100644 --- a/src/tui/tui-command-handlers.test.ts +++ b/src/tui/tui-command-handlers.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { createCommandHandlers } from "./tui-command-handlers.js"; type LoadHistoryMock = ReturnType & (() => Promise); +type RunAuthFlow = NonNullable[0]["runAuthFlow"]>; type SetActivityStatusMock = ReturnType & ((text: string) => void); type SetSessionMock = ReturnType & ((key: string) => Promise); @@ -10,6 +11,7 @@ function createHarness(params?: { getGatewayStatus?: ReturnType; patchSession?: ReturnType; resetSession?: ReturnType; + runAuthFlow?: RunAuthFlow; setSession?: SetSessionMock; loadHistory?: LoadHistoryMock; refreshSessionInfo?: ReturnType; @@ -17,6 +19,8 @@ function createHarness(params?: { setActivityStatus?: SetActivityStatusMock; isConnected?: boolean; activeChatRunId?: string | null; + pendingOptimisticUserMessage?: boolean; + opts?: { local?: boolean }; }) { const sendChat = params?.sendChat ?? vi.fn().mockResolvedValue({ runId: "r1" }); const getGatewayStatus = params?.getGatewayStatus ?? vi.fn().mockResolvedValue({}); @@ -33,10 +37,15 @@ function createHarness(params?: { const refreshSessionInfo = params?.refreshSessionInfo ?? vi.fn().mockResolvedValue(undefined); const applySessionInfoFromPatch = params?.applySessionInfoFromPatch ?? vi.fn(); const setActivityStatus = params?.setActivityStatus ?? (vi.fn() as SetActivityStatusMock); + const runAuthFlow: RunAuthFlow | undefined = + params?.runAuthFlow ?? + (params?.opts?.local + ? (vi.fn().mockResolvedValue({ exitCode: 0, signal: null }) as unknown as RunAuthFlow) + : undefined); const state = { currentSessionKey: "agent:main:main", activeChatRunId: params?.activeChatRunId ?? null, - pendingOptimisticUserMessage: false, + pendingOptimisticUserMessage: params?.pendingOptimisticUserMessage ?? false, isConnected: params?.isConnected ?? true, sessionInfo: {}, }; @@ -45,7 +54,7 @@ function createHarness(params?: { client: { sendChat, getGatewayStatus, patchSession, resetSession } as never, chatLog: { addUser, addSystem } as never, tui: { requestRender } as never, - opts: {}, + opts: params?.opts ?? {}, state: state as never, deliverDefault: false, openOverlay: vi.fn(), @@ -62,6 +71,7 @@ function createHarness(params?: { noteLocalBtwRunId, forgetLocalRunId: vi.fn(), forgetLocalBtwRunId: vi.fn(), + runAuthFlow, requestExit: vi.fn(), }); @@ -78,6 +88,7 @@ function createHarness(params?: { loadHistory, refreshSessionInfo, applySessionInfoFromPatch, + runAuthFlow, setActivityStatus, noteLocalRunId, noteLocalBtwRunId, @@ -259,6 +270,48 @@ describe("tui command handlers", () => { expect(setActivityStatus).toHaveBeenLastCalledWith("disconnected"); }); + it("runs /auth through the local auth flow and refreshes session info", async () => { + const refreshSessionInfo = vi.fn().mockResolvedValue(undefined); + const runAuthFlow = vi.fn().mockResolvedValue({ exitCode: 0, signal: null }); + const { handleCommand, addSystem, setActivityStatus } = createHarness({ + opts: { local: true }, + refreshSessionInfo, + runAuthFlow, + }); + + await handleCommand("/auth openai-codex"); + + expect(runAuthFlow).toHaveBeenCalledWith({ provider: "openai-codex" }); + expect(refreshSessionInfo).toHaveBeenCalledTimes(1); + expect(addSystem).toHaveBeenCalledWith( + "opening auth flow for openai-codex; TUI will resume when it exits", + ); + expect(addSystem).toHaveBeenCalledWith("auth flow finished for openai-codex"); + expect(setActivityStatus).toHaveBeenLastCalledWith("idle"); + }); + + it("rejects /auth in non-local mode", async () => { + const { handleCommand, addSystem } = createHarness(); + + await handleCommand("/auth"); + + expect(addSystem).toHaveBeenCalledWith("auth login is only available in local embedded mode"); + }); + + it("blocks /auth while an optimistic run is still pending", async () => { + const runAuthFlow = vi.fn().mockResolvedValue({ exitCode: 0, signal: null }); + const { handleCommand, addSystem } = createHarness({ + opts: { local: true }, + pendingOptimisticUserMessage: true, + runAuthFlow, + }); + + await handleCommand("/auth openai-codex"); + + expect(runAuthFlow).not.toHaveBeenCalled(); + expect(addSystem).toHaveBeenCalledWith("abort the current run before /auth"); + }); + it("rejects invalid /activation values before patching the session", async () => { const { handleCommand, patchSession, addSystem } = createHarness(); diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 91f471d0df4..a714be8996e 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -16,7 +16,7 @@ import { createSearchableSelectList, createSettingsList, } from "./components/selectors.js"; -import type { GatewayChatClient } from "./gateway-chat.js"; +import type { TuiBackend } from "./tui-backend.js"; import { sanitizeRenderableText } from "./tui-formatters.js"; import { formatStatusSummary } from "./tui-status-summary.js"; import type { @@ -27,7 +27,7 @@ import type { } from "./tui-types.js"; type CommandHandlerContext = { - client: GatewayChatClient; + client: TuiBackend; chatLog: ChatLog; tui: TUI; opts: TuiOptions; @@ -43,10 +43,13 @@ type CommandHandlerContext = { setActivityStatus: (text: string) => void; formatSessionKey: (key: string) => string; applySessionInfoFromPatch: (result: SessionsPatchResult) => void; - noteLocalRunId: (runId: string) => void; + noteLocalRunId?: (runId: string) => void; noteLocalBtwRunId?: (runId: string) => void; forgetLocalRunId?: (runId: string) => void; forgetLocalBtwRunId?: (runId: string) => void; + runAuthFlow?: (params: { + provider?: string; + }) => Promise<{ exitCode: number | null; signal: NodeJS.Signals | null }>; requestExit: () => void; }; @@ -75,6 +78,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { noteLocalBtwRunId, forgetLocalRunId, forgetLocalBtwRunId, + runAuthFlow, requestExit, } = context; @@ -250,11 +254,52 @@ export function createCommandHandlers(context: CommandHandlerContext) { case "help": chatLog.addSystem( helpText({ + local: opts.local, provider: state.sessionInfo.modelProvider, model: state.sessionInfo.model, }), ); break; + case "auth": { + if (!runAuthFlow) { + chatLog.addSystem("auth login is only available in local embedded mode"); + break; + } + if (state.activeChatRunId || state.pendingOptimisticUserMessage) { + chatLog.addSystem("abort the current run before /auth"); + break; + } + const provider = args.trim() || state.sessionInfo.modelProvider || undefined; + chatLog.addSystem( + provider + ? `opening auth flow for ${provider}; TUI will resume when it exits` + : "opening auth flow; TUI will resume when it exits", + ); + tui.requestRender(); + setActivityStatus("auth"); + try { + const result = await runAuthFlow({ provider }); + await refreshSessionInfo(); + if (result.exitCode === 0 && !result.signal) { + chatLog.addSystem( + provider ? `auth flow finished for ${provider}` : "auth flow finished", + ); + setActivityStatus("idle"); + } else { + const failureSuffix = result.signal + ? ` (signal ${result.signal})` + : typeof result.exitCode === "number" + ? ` (exit ${String(result.exitCode)})` + : ""; + chatLog.addSystem(`auth flow failed${failureSuffix}`); + setActivityStatus("error"); + } + } catch (err) { + chatLog.addSystem(`auth flow failed: ${sanitizeRenderableText(String(err))}`); + setActivityStatus("error"); + } + break; + } case "gateway-status": try { const status = await client.getGatewayStatus(); @@ -526,7 +571,11 @@ export function createCommandHandlers(context: CommandHandlerContext) { const sendMessage = async (text: string) => { if (!state.isConnected) { - chatLog.addSystem("not connected to gateway — message not sent"); + chatLog.addSystem( + opts.local + ? "local runtime not ready — message not sent" + : "not connected to gateway — message not sent", + ); setActivityStatus("disconnected"); tui.requestRender(); return; diff --git a/src/tui/tui-event-handlers.test.ts b/src/tui/tui-event-handlers.test.ts index a73de0be466..3cd0480301f 100644 --- a/src/tui/tui-event-handlers.test.ts +++ b/src/tui/tui-event-handlers.test.ts @@ -116,6 +116,7 @@ describe("tui-event-handlers: handleAgentEvent", () => { state?: Partial; chatLog?: HandlerChatLog; btw?: HandlerBtwPresenter; + localMode?: boolean; }) => { const state = makeState(params?.state); const context = makeContext(state); @@ -125,6 +126,7 @@ describe("tui-event-handlers: handleAgentEvent", () => { btw: (params?.btw ?? context.btw) as MockBtwPresenter & HandlerBtwPresenter, tui: context.tui, state, + localMode: params?.localMode, setActivityStatus: context.setActivityStatus, loadHistory: context.loadHistory, noteLocalRunId: context.noteLocalRunId, @@ -571,6 +573,28 @@ describe("tui-event-handlers: handleAgentEvent", () => { expect(chatLog.dropAssistant).not.toHaveBeenCalledWith("run-error-envelope"); }); + it("shows a concise /auth hint for local auth failures", () => { + const { chatLog, handleChatEvent } = createHandlersHarness({ + localMode: true, + state: { + activeChatRunId: null, + sessionInfo: { modelProvider: "openai-codex" }, + }, + }); + + handleChatEvent({ + runId: "run-auth-error", + sessionKey: "agent:main:main", + state: "error", + errorMessage: + "Authentication failed with an HTML 403 response from the provider. Re-authenticate and verify your provider account access.", + }); + + expect(chatLog.addSystem).toHaveBeenCalledWith( + "auth or provider access failed for openai-codex. Run /auth openai-codex to refresh credentials; if you already re-authed, switch models/providers because this account may still be blocked for inference.", + ); + }); + it("drops streaming assistant when chat final has no message", () => { const { state, chatLog, handleChatEvent } = createHandlersHarness({ state: { activeChatRunId: null }, diff --git a/src/tui/tui-event-handlers.ts b/src/tui/tui-event-handlers.ts index 8ac9262bcc8..06c7b83c1b5 100644 --- a/src/tui/tui-event-handlers.ts +++ b/src/tui/tui-event-handlers.ts @@ -1,3 +1,4 @@ +import { isAuthErrorMessage } from "../agents/pi-embedded-helpers.js"; import { parseAgentSessionKey } from "../sessions/session-key-utils.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { asString, extractTextFromMessage, isCommandMessage } from "./tui-formatters.js"; @@ -43,6 +44,7 @@ type EventHandlerContext = { clearLocalBtwRunIds?: () => void; /** Reset `streaming` after this much delta silence. Set to 0 to disable. */ streamingWatchdogMs?: number; + localMode?: boolean; }; const DEFAULT_STREAMING_WATCHDOG_MS = 30_000; @@ -63,6 +65,7 @@ export function createEventHandlers(context: EventHandlerContext) { isLocalBtwRunId, forgetLocalBtwRunId, clearLocalBtwRunIds, + localMode, } = context; const finalizedRuns = new Map(); const sessionRuns = new Map(); @@ -163,6 +166,16 @@ export function createEventHandlers(context: EventHandlerContext) { void loadHistory?.(); }; + const resolveAuthErrorHint = (errorMessage: string): string | undefined => { + if (!localMode || !isAuthErrorMessage(errorMessage)) { + return undefined; + } + const provider = state.sessionInfo.modelProvider?.trim(); + return provider + ? `auth or provider access failed for ${provider}. Run /auth ${provider} to refresh credentials; if you already re-authed, switch models/providers because this account may still be blocked for inference.` + : "auth or provider access failed for the current provider. Run /auth to refresh credentials; if you already re-authed, switch models/providers because this account may still be blocked for inference."; + }; + const noteSessionRun = (runId: string) => { sessionRuns.set(runId, Date.now()); pruneRunMap(sessionRuns); @@ -376,7 +389,8 @@ export function createEventHandlers(context: EventHandlerContext) { if (evt.state === "error") { forgetLocalBtwRunId?.(evt.runId); const wasActiveRun = state.activeChatRunId === evt.runId; - chatLog.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`); + const errorMessage = evt.errorMessage ?? "unknown"; + chatLog.addSystem(resolveAuthErrorHint(errorMessage) ?? `run error: ${errorMessage}`); terminateRun({ runId: evt.runId, wasActiveRun, status: "error" }); maybeRefreshHistoryForRun(evt.runId); } diff --git a/src/tui/tui-launch.test.ts b/src/tui/tui-launch.test.ts index 27058f9b56f..5fb9ae6cfc4 100644 --- a/src/tui/tui-launch.test.ts +++ b/src/tui/tui-launch.test.ts @@ -86,6 +86,22 @@ describe("launchTuiCli", () => { ); }); + it("passes local mode through to the relaunched TUI", async () => { + const child = createChildProcess(); + spawnMock.mockImplementation((_cmd: string, _args: string[], _opts: SpawnOptions) => { + queueMicrotask(() => child.emit("exit", 0, null)); + return child; + }); + + await launchTuiCli({ local: true, deliver: false }); + + expect(spawnMock).toHaveBeenCalledWith( + process.execPath, + ["/repo/openclaw.mjs", "tui", "--local"], + expect.objectContaining({ stdio: "inherit" }), + ); + }); + it("launches compiled CLI shapes without repeating the current command", async () => { process.argv[1] = "setup"; const child = createChildProcess(); diff --git a/src/tui/tui-launch.ts b/src/tui/tui-launch.ts index 157e8a2b647..df459b26182 100644 --- a/src/tui/tui-launch.ts +++ b/src/tui/tui-launch.ts @@ -60,6 +60,9 @@ function buildCurrentCliEntryArgs(): string[] { function buildTuiCliArgs(opts: TuiOptions): string[] { const args = [...filterTuiExecArgv(process.execArgv), ...buildCurrentCliEntryArgs(), "tui"]; + if (opts.local) { + args.push("--local"); + } appendOption(args, "--url", opts.url); appendOption(args, "--token", opts.token); appendOption(args, "--password", opts.password); diff --git a/src/tui/tui-session-actions.test.ts b/src/tui/tui-session-actions.test.ts index c175ea7520a..a76333e6d90 100644 --- a/src/tui/tui-session-actions.test.ts +++ b/src/tui/tui-session-actions.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import type { GatewayChatClient } from "./gateway-chat.js"; +import type { TuiBackend } from "./tui-backend.js"; import { createSessionActions } from "./tui-session-actions.js"; import type { TuiStateAccess } from "./tui-types.js"; @@ -36,7 +36,7 @@ describe("tui session actions", () => { overrides: Partial[0]>, ) => createSessionActions({ - client: { listSessions: vi.fn() } as unknown as GatewayChatClient, + client: { listSessions: vi.fn() } as unknown as TuiBackend, chatLog: { addSystem: vi.fn(), clearAll: vi.fn(), @@ -82,7 +82,7 @@ describe("tui session actions", () => { const requestRender = vi.fn(); const { refreshSessionInfo } = createTestSessionActions({ - client: { listSessions } as unknown as GatewayChatClient, + client: { listSessions } as unknown as TuiBackend, chatLog: { addSystem: vi.fn() } as unknown as import("./components/chat-log.js").ChatLog, btw: createBtwPresenter(), tui: { requestRender } as unknown as import("@mariozechner/pi-tui").TUI, @@ -163,7 +163,7 @@ describe("tui session actions", () => { }); const { applySessionInfoFromPatch, refreshSessionInfo } = createTestSessionActions({ - client: { listSessions } as unknown as GatewayChatClient, + client: { listSessions } as unknown as TuiBackend, state, }); @@ -224,7 +224,7 @@ describe("tui session actions", () => { client: { listSessions, loadHistory, - } as unknown as GatewayChatClient, + } as unknown as TuiBackend, btw, state, setActivityStatus, @@ -244,6 +244,65 @@ describe("tui session actions", () => { expect(btw.clear).toHaveBeenCalled(); }); + it("applies default model info when the current session has no persisted entry yet", async () => { + const listSessions = vi.fn().mockResolvedValue({ + ts: Date.now(), + path: "/tmp/sessions.json", + count: 0, + defaults: { + model: "gpt-5.4", + modelProvider: "openai", + contextTokens: 272000, + }, + sessions: [], + }); + + const state: TuiStateAccess = { + agentDefaultId: "main", + sessionMainKey: "agent:main:main", + sessionScope: "global", + agents: [], + currentAgentId: "main", + currentSessionKey: "agent:main:brand-new", + currentSessionId: null, + activeChatRunId: null, + historyLoaded: false, + sessionInfo: {}, + initialSessionApplied: true, + isConnected: true, + autoMessageSent: false, + toolsExpanded: false, + showThinking: false, + connectionStatus: "connected", + activityStatus: "idle", + statusTimeout: null, + lastCtrlCAt: 0, + }; + + const { refreshSessionInfo } = createSessionActions({ + client: { listSessions } as unknown as TuiBackend, + chatLog: { addSystem: vi.fn() } as unknown as import("./components/chat-log.js").ChatLog, + btw: createBtwPresenter(), + tui: { requestRender: vi.fn() } as unknown as import("@mariozechner/pi-tui").TUI, + opts: {}, + state, + agentNames: new Map(), + initialSessionInput: "", + initialSessionAgentId: null, + resolveSessionKey: vi.fn(), + updateHeader: vi.fn(), + updateFooter: vi.fn(), + updateAutocompleteProvider: vi.fn(), + setActivityStatus: vi.fn(), + }); + + await refreshSessionInfo(); + + expect(state.sessionInfo.model).toBe("gpt-5.4"); + expect(state.sessionInfo.modelProvider).toBe("openai"); + expect(state.sessionInfo.contextTokens).toBe(272000); + }); + it("resets activity status to idle when switching sessions after streaming", async () => { const listSessions = vi.fn().mockResolvedValue({ ts: Date.now(), @@ -268,7 +327,7 @@ describe("tui session actions", () => { client: { listSessions, loadHistory, - } as unknown as GatewayChatClient, + } as unknown as TuiBackend, state, setActivityStatus, }); diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index 4b291ebecb6..6dc1ec7014f 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -8,7 +8,7 @@ import { } from "../routing/session-key.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import type { ChatLog } from "./components/chat-log.js"; -import type { GatewayAgentsList, GatewayChatClient } from "./gateway-chat.js"; +import type { TuiAgentsList, TuiBackend } from "./tui-backend.js"; import { asString, extractTextFromMessage, isCommandMessage } from "./tui-formatters.js"; import type { SessionInfo, TuiOptions, TuiStateAccess } from "./tui-types.js"; @@ -17,7 +17,7 @@ type SessionActionBtwPresenter = { }; type SessionActionContext = { - client: GatewayChatClient; + client: TuiBackend; chatLog: ChatLog; btw: SessionActionBtwPresenter; tui: TUI; @@ -66,7 +66,7 @@ export function createSessionActions(context: SessionActionContext) { let refreshSessionInfoPromise: Promise = Promise.resolve(); let lastSessionDefaults: SessionInfoDefaults | null = null; - const applyAgentsResult = (result: GatewayAgentsList) => { + const applyAgentsResult = (result: TuiAgentsList) => { state.agentDefaultId = normalizeAgentId(result.defaultId); state.sessionMainKey = normalizeMainKey(result.mainKey); state.sessionScope = result.scope ?? state.sessionScope; @@ -126,6 +126,8 @@ export function createSessionActions(context: SessionActionContext) { return resolveSessionInfoModelSelection({ currentProvider: state.sessionInfo.modelProvider, currentModel: state.sessionInfo.model, + defaultProvider: lastSessionDefaults?.modelProvider, + defaultModel: lastSessionDefaults?.model, entryProvider: entry?.modelProvider, entryModel: entry?.model, overrideProvider: entry?.providerOverride, diff --git a/src/tui/tui-types.ts b/src/tui/tui-types.ts index a9b3fcc094d..547f3b5aa48 100644 --- a/src/tui/tui-types.ts +++ b/src/tui/tui-types.ts @@ -1,4 +1,5 @@ export type TuiOptions = { + local?: boolean; url?: string; token?: string; password?: string; diff --git a/src/tui/tui.test.ts b/src/tui/tui.test.ts index e1f4427d401..5a573090239 100644 --- a/src/tui/tui.test.ts +++ b/src/tui/tui.test.ts @@ -5,10 +5,14 @@ import { createBackspaceDeduper, drainAndStopTuiSafely, isIgnorableTuiStopError, + resolveCodexCliBin, resolveCtrlCAction, resolveFinalAssistantText, resolveGatewayDisconnectState, resolveInitialTuiAgentId, + resolveLocalAuthCliInvocation, + resolveLocalAuthSpawnCwd, + resolveLocalAuthSpawnOptions, resolveTuiSessionKey, stopTuiSafely, } from "./tui.js"; @@ -55,6 +59,11 @@ describe("tui slash commands", () => { expect(commands.some((command) => command.name === "context")).toBe(true); expect(commands.some((command) => command.name === "commands")).toBe(true); }); + + it("includes /auth in local embedded mode", () => { + const commands = getSlashCommands({ local: true }); + expect(commands.some((command) => command.name === "auth")).toBe(true); + }); }); describe("resolveTuiSessionKey", () => { @@ -310,3 +319,118 @@ describe("TUI shutdown safety", () => { }).toThrow("boom"); }); }); + +describe("resolveCodexCliBin", () => { + it("returns a string path when codex CLI is installed", () => { + const result = resolveCodexCliBin(); + // In this test environment codex is installed; verify it returns a non-empty path + if (result !== null) { + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + expect(result).toContain("codex"); + } + }); + + it("returns null or a valid path (never throws)", () => { + // The function should never throw regardless of environment + expect(() => resolveCodexCliBin()).not.toThrow(); + const result = resolveCodexCliBin(); + expect(result === null || typeof result === "string").toBe(true); + }); +}); + +describe("resolveLocalAuthCliInvocation", () => { + it("uses the source runner when dist is unavailable", () => { + expect( + resolveLocalAuthCliInvocation({ + execPath: "/usr/bin/node", + wrapperPath: "/repo/openclaw.mjs", + runNodePath: "/repo/scripts/run-node.mjs", + hasDistEntry: false, + hasRunNodeScript: true, + }), + ).toEqual({ + command: "/usr/bin/node", + args: ["/repo/scripts/run-node.mjs", "models", "auth", "login"], + }); + }); + + it("uses the packaged wrapper when dist is available", () => { + expect( + resolveLocalAuthCliInvocation({ + execPath: "/usr/bin/node", + wrapperPath: "/repo/openclaw.mjs", + runNodePath: "/repo/scripts/run-node.mjs", + hasDistEntry: true, + hasRunNodeScript: true, + }), + ).toEqual({ + command: "/usr/bin/node", + args: ["/repo/openclaw.mjs", "models", "auth", "login"], + }); + }); +}); + +describe("resolveLocalAuthSpawnOptions", () => { + it("enables shell mode for Windows cmd shims", () => { + expect( + resolveLocalAuthSpawnOptions({ + command: "C:\\Users\\me\\AppData\\Roaming\\npm\\codex.cmd", + platform: "win32", + }), + ).toEqual({ shell: true }); + }); + + it("enables shell mode for Windows bat shims", () => { + expect( + resolveLocalAuthSpawnOptions({ + command: "C:\\tools\\codex.bat", + platform: "win32", + }), + ).toEqual({ shell: true }); + }); + + it("keeps direct execution for non-wrapper commands", () => { + expect( + resolveLocalAuthSpawnOptions({ + command: "/usr/local/bin/codex", + platform: "linux", + }), + ).toEqual({}); + expect( + resolveLocalAuthSpawnOptions({ + command: "C:\\tools\\codex.exe", + platform: "win32", + }), + ).toEqual({}); + }); +}); + +describe("resolveLocalAuthSpawnCwd", () => { + it("runs the packaged wrapper from the repo root", () => { + expect( + resolveLocalAuthSpawnCwd({ + args: ["/repo/openclaw.mjs", "models", "auth", "login"], + defaultCwd: "/worktree/subdir", + }), + ).toBe("/repo"); + }); + + it("runs the source fallback helper from the repo root", () => { + expect( + resolveLocalAuthSpawnCwd({ + args: ["/repo/scripts/run-node.mjs", "models", "auth", "login"], + defaultCwd: "/worktree/subdir", + }), + ).toBe("/repo"); + }); + + it("keeps the caller cwd for direct codex exec", () => { + expect( + resolveLocalAuthSpawnCwd({ + args: ["login"], + defaultCwd: "/worktree/subdir", + }), + ).toBe("/worktree/subdir"); + }); +}); diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 83b063f6d88..822d9d50c3e 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -1,3 +1,7 @@ +import { execFileSync, spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { CombinedAutocompleteProvider, Container, @@ -10,6 +14,8 @@ import { } from "@mariozechner/pi-tui"; import { resolveAgentIdByWorkspacePath, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { loadConfig, type OpenClawConfig } from "../config/config.js"; +import { setConsoleSubsystemFilter } from "../logging/console.js"; +import { loggingState } from "../logging/state.js"; import { buildAgentMainSessionKey, normalizeAgentId, @@ -20,8 +26,10 @@ import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { getSlashCommands } from "./commands.js"; import { ChatLog } from "./components/chat-log.js"; import { CustomEditor } from "./components/custom-editor.js"; +import { EmbeddedTuiBackend } from "./embedded-backend.js"; import { GatewayChatClient } from "./gateway-chat.js"; import { editorTheme, theme } from "./theme/theme.js"; +import type { TuiBackend } from "./tui-backend.js"; import { createCommandHandlers } from "./tui-command-handlers.js"; import { createEventHandlers } from "./tui-event-handlers.js"; import { formatTokens } from "./tui-formatters.js"; @@ -50,6 +58,77 @@ export { shouldEnableWindowsGitBashPasteFallback, } from "./tui-submit.js"; +const OPENCLAW_CLI_WRAPPER_PATH = fileURLToPath(new URL("../../openclaw.mjs", import.meta.url)); +const OPENCLAW_RUN_NODE_SCRIPT_PATH = fileURLToPath( + new URL("../../scripts/run-node.mjs", import.meta.url), +); +const OPENCLAW_DIST_ENTRY_JS_PATH = fileURLToPath(new URL("../../dist/entry.js", import.meta.url)); +const OPENCLAW_DIST_ENTRY_MJS_PATH = fileURLToPath( + new URL("../../dist/entry.mjs", import.meta.url), +); + +const OPENAI_CODEX_PROVIDER = "openai-codex"; + +/** Resolve the absolute path to the `codex` CLI binary, or `null` if not installed. */ +export function resolveCodexCliBin(): string | null { + try { + const lookupCmd = process.platform === "win32" ? "where" : "which"; + // `where` on Windows can return multiple lines; take the first match. + const raw = execFileSync(lookupCmd, ["codex"], { encoding: "utf8" }).trim(); + return raw.split(/\r?\n/)[0] || null; + } catch { + return null; + } +} + +export function resolveLocalAuthCliInvocation(params?: { + execPath?: string; + wrapperPath?: string; + runNodePath?: string; + hasDistEntry?: boolean; + hasRunNodeScript?: boolean; +}): { command: string; args: string[] } { + const hasDistEntry = + params?.hasDistEntry ?? + (existsSync(OPENCLAW_DIST_ENTRY_JS_PATH) || existsSync(OPENCLAW_DIST_ENTRY_MJS_PATH)); + const hasRunNodeScript = params?.hasRunNodeScript ?? existsSync(OPENCLAW_RUN_NODE_SCRIPT_PATH); + const command = params?.execPath ?? process.execPath; + const wrapperPath = params?.wrapperPath ?? OPENCLAW_CLI_WRAPPER_PATH; + const runNodePath = params?.runNodePath ?? OPENCLAW_RUN_NODE_SCRIPT_PATH; + + // Prefer the packaged wrapper when build output exists, but keep source-tree + // auth working in unbuilt checkouts that only have scripts/run-node.mjs. + return hasDistEntry || !hasRunNodeScript + ? { command, args: [wrapperPath, "models", "auth", "login"] } + : { command, args: [runNodePath, "models", "auth", "login"] }; +} + +export function resolveLocalAuthSpawnOptions(params: { + command: string; + platform?: NodeJS.Platform; +}): { shell?: true } { + const platform = params.platform ?? process.platform; + return platform === "win32" && /\.(cmd|bat)$/iu.test(params.command.trim()) + ? { shell: true } + : {}; +} + +export function resolveLocalAuthSpawnCwd(params: { args: string[]; defaultCwd?: string }): string { + const defaultCwd = params.defaultCwd ?? process.cwd(); + const entryArg = params.args[0]?.trim(); + if (!entryArg) { + return defaultCwd; + } + const entryBase = path.basename(entryArg).toLowerCase(); + if (entryBase === "openclaw.mjs") { + return path.dirname(entryArg); + } + if (entryBase === "run-node.mjs") { + return path.dirname(path.dirname(entryArg)); + } + return defaultCwd; +} + export function resolveTuiSessionKey(params: { raw?: string; sessionScope: SessionScope; @@ -205,6 +284,7 @@ export function resolveCtrlCAction(params: { } export async function runTui(opts: TuiOptions) { + const isLocalMode = opts.local === true; const config = loadConfig(); const initialSessionInput = (opts.session ?? "").trim(); let sessionScope: SessionScope = (config.session?.scope ?? "per-sender") as SessionScope; @@ -239,7 +319,7 @@ export async function runTui(opts: TuiOptions) { let lastCtrlCAt = 0; let exitRequested = false; let activityStatus = "idle"; - let connectionStatus = "connecting"; + let connectionStatus = isLocalMode ? "starting local runtime" : "connecting"; let statusTimeout: NodeJS.Timeout | null = null; let statusTimer: NodeJS.Timeout | null = null; let statusStartedAt: number | null = null; @@ -414,11 +494,21 @@ export async function runTui(opts: TuiOptions) { localBtwRunIds.clear(); }; - const client = await GatewayChatClient.connect({ - url: opts.url, - token: opts.token, - password: opts.password, - }); + const client: TuiBackend = opts.local + ? new EmbeddedTuiBackend() + : await GatewayChatClient.connect({ + url: opts.url, + token: opts.token, + password: opts.password, + }); + const previousConsoleSubsystemFilter = isLocalMode + ? loggingState.consoleSubsystemFilter + ? [...loggingState.consoleSubsystemFilter] + : null + : null; + if (isLocalMode) { + setConsoleSubsystemFilter(["__openclaw_tui_quiet__"]); + } const tui = new TUI(new ProcessTerminal()); const dedupeBackspace = createBackspaceDeduper(); @@ -446,6 +536,7 @@ export async function runTui(opts: TuiOptions) { new CombinedAutocompleteProvider( getSlashCommands({ cfg: config, + local: isLocalMode, provider: sessionInfo.modelProvider, model: sessionInfo.model, }), @@ -644,7 +735,13 @@ export async function runTui(opts: TuiOptions) { } if (ttlMs && ttlMs > 0) { statusTimeout = setTimeout(() => { - connectionStatus = isConnected ? "connected" : "disconnected"; + connectionStatus = isConnected + ? isLocalMode + ? "local ready" + : "connected" + : isLocalMode + ? "local stopped" + : "disconnected"; renderStatus(); }, ttlMs); } @@ -655,6 +752,67 @@ export async function runTui(opts: TuiOptions) { renderStatus(); }; + const withTuiSuspended = async (work: () => Promise): Promise => { + await drainAndStopTuiSafely(tui); + if (isLocalMode) { + setConsoleSubsystemFilter(previousConsoleSubsystemFilter); + } + try { + return await work(); + } finally { + if (isLocalMode) { + setConsoleSubsystemFilter(["__openclaw_tui_quiet__"]); + } + tui.start(); + tui.setFocus(editor); + updateHeader(); + updateFooter(); + tui.requestRender(true); + } + }; + + const runAuthFlow = isLocalMode + ? async (params: { provider?: string }) => + await withTuiSuspended( + async () => + await new Promise<{ exitCode: number | null; signal: NodeJS.Signals | null }>( + (resolve, reject) => { + const provider = params.provider?.trim() || undefined; + + // Codex owns its auth store; delegate when the CLI is available. + const codexBin = + provider === OPENAI_CODEX_PROVIDER || + (!provider && sessionInfo.modelProvider === OPENAI_CODEX_PROVIDER) + ? resolveCodexCliBin() + : null; + + let command: string; + let args: string[]; + if (codexBin) { + command = codexBin; + args = ["login"]; + } else { + ({ command, args } = resolveLocalAuthCliInvocation()); + if (provider) { + args.push("--provider", provider); + } + } + + const child = spawn(command, args, { + cwd: resolveLocalAuthSpawnCwd({ args, defaultCwd: process.cwd() }), + env: process.env, + stdio: "inherit", + ...resolveLocalAuthSpawnOptions({ command }), + }); + child.once("error", reject); + child.once("exit", (exitCode, signal) => { + resolve({ exitCode, signal }); + }); + }, + ), + ) + : undefined; + const updateFooter = () => { const sessionKeyLabel = formatSessionKey(currentSessionKey); const sessionLabel = sessionInfo.displayName @@ -735,6 +893,7 @@ export async function runTui(opts: TuiOptions) { btw, tui, state, + localMode: isLocalMode, setActivityStatus, refreshSessionInfo, loadHistory, @@ -780,6 +939,7 @@ export async function runTui(opts: TuiOptions) { noteLocalBtwRunId, forgetLocalRunId, forgetLocalBtwRunId, + runAuthFlow, requestExit, }); @@ -888,12 +1048,15 @@ export async function runTui(opts: TuiOptions) { pairingHintShown = false; const reconnected = wasDisconnected; wasDisconnected = false; - setConnectionStatus("connected"); + setConnectionStatus(isLocalMode ? "local ready" : "connected"); void (async () => { await refreshAgents(); updateHeader(); await loadHistory(); - setConnectionStatus(reconnected ? "gateway reconnected" : "gateway connected", 4000); + setConnectionStatus( + isLocalMode ? "local ready" : reconnected ? "gateway reconnected" : "gateway connected", + 4000, + ); tui.requestRender(); if (!autoMessageSent && autoMessage) { autoMessageSent = true; @@ -908,7 +1071,13 @@ export async function runTui(opts: TuiOptions) { isConnected = false; wasDisconnected = true; historyLoaded = false; - const disconnectState = resolveGatewayDisconnectState(reason); + const disconnectState = isLocalMode + ? { + connectionStatus: `local runtime stopped${reason ? `: ${reason}` : ""}`, + activityStatus: "idle", + pairingHint: undefined, + } + : resolveGatewayDisconnectState(reason); setConnectionStatus(disconnectState.connectionStatus, 5000); setActivityStatus(disconnectState.activityStatus); if (disconnectState.pairingHint && !pairingHintShown) { @@ -925,7 +1094,7 @@ export async function runTui(opts: TuiOptions) { }; updateHeader(); - setConnectionStatus("connecting"); + setConnectionStatus(isLocalMode ? "starting local runtime" : "connecting"); updateFooter(); const sigintHandler = () => { handleCtrlC(); @@ -939,6 +1108,9 @@ export async function runTui(opts: TuiOptions) { client.start(); await new Promise((resolve) => { const finish = () => { + if (isLocalMode) { + setConsoleSubsystemFilter(previousConsoleSubsystemFilter); + } process.removeListener("SIGINT", sigintHandler); process.removeListener("SIGTERM", sigtermHandler); resolve(); diff --git a/src/wizard/setup.finalize.test.ts b/src/wizard/setup.finalize.test.ts index 1fc11f1f779..1cf41285b1b 100644 --- a/src/wizard/setup.finalize.test.ts +++ b/src/wizard/setup.finalize.test.ts @@ -339,16 +339,11 @@ describe("finalizeSetupWizard", () => { password: "resolved-gateway-password", // pragma: allowlist secret }), ); - expect(launchTuiCli).toHaveBeenCalledWith( - { - deliver: false, - message: undefined, - }, - { - authSource: "config", - gatewayUrl: "ws://127.0.0.1:18789", - }, - ); + expect(launchTuiCli).toHaveBeenCalledWith({ + local: true, + deliver: false, + message: undefined, + }); }); it("restores terminal state after failed TUI hatch", async () => { diff --git a/src/wizard/setup.finalize.ts b/src/wizard/setup.finalize.ts index 7f930411b8a..c3c842552db 100644 --- a/src/wizard/setup.finalize.ts +++ b/src/wizard/setup.finalize.ts @@ -385,7 +385,7 @@ export async function finalizeSetupWizard( let hatchChoice: "tui" | "web" | "later" | null = null; let launchedTui = false; - if (!opts.skipUi && gatewayProbe.ok) { + if (!opts.skipUi) { if (hasBootstrap) { await prompter.note( [ @@ -398,43 +398,41 @@ export async function finalizeSetupWizard( ); } - await prompter.note( - [ - "Gateway token: shared auth for the Gateway + Control UI.", - "Stored in: $OPENCLAW_CONFIG_PATH (default: ~/.openclaw/openclaw.json) under gateway.auth.token, or in OPENCLAW_GATEWAY_TOKEN.", - `View token: ${formatCliCommand("openclaw config get gateway.auth.token")}`, - `Generate token: ${formatCliCommand("openclaw doctor --generate-gateway-token")}`, - "Web UI keeps dashboard URL tokens in memory for the current tab and strips them from the URL after load.", - `Open the dashboard anytime: ${formatCliCommand("openclaw dashboard --no-open")}`, - "If prompted: paste the token into Control UI settings (or use the tokenized dashboard URL).", - ].join("\n"), - "Token", - ); + if (gatewayProbe.ok) { + await prompter.note( + [ + "Gateway token: shared auth for the Gateway + Control UI.", + "Stored in: $OPENCLAW_CONFIG_PATH (default: ~/.openclaw/openclaw.json) under gateway.auth.token, or in OPENCLAW_GATEWAY_TOKEN.", + `View token: ${formatCliCommand("openclaw config get gateway.auth.token")}`, + `Generate token: ${formatCliCommand("openclaw doctor --generate-gateway-token")}`, + "Web UI keeps dashboard URL tokens in memory for the current tab and strips them from the URL after load.", + `Open the dashboard anytime: ${formatCliCommand("openclaw dashboard --no-open")}`, + "If prompted: paste the token into Control UI settings (or use the tokenized dashboard URL).", + ].join("\n"), + "Token", + ); + } + + const hatchOptions: { value: "tui" | "web" | "later"; label: string }[] = [ + { value: "tui", label: "Hatch in Terminal (recommended)" }, + ...(gatewayProbe.ok ? [{ value: "web" as const, label: "Open the Web UI" }] : []), + { value: "later", label: "Do this later" }, + ]; hatchChoice = await prompter.select({ message: "How do you want to hatch your bot?", - options: [ - { value: "tui", label: "Hatch in TUI (recommended)" }, - { value: "web", label: "Open the Web UI" }, - { value: "later", label: "Do this later" }, - ], + options: hatchOptions, initialValue: "tui", }); if (hatchChoice === "tui") { restoreTerminalState("pre-setup tui", { resumeStdinIfPaused: true }); try { - await launchTuiCli( - { - // Safety: setup TUI should not auto-deliver to lastProvider/lastTo. - deliver: false, - message: hasBootstrap ? "Wake up, my friend!" : undefined, - }, - { - authSource: "config", - gatewayUrl: links.wsUrl, - }, - ); + await launchTuiCli({ + local: true, + deliver: false, + message: hasBootstrap ? "Wake up, my friend!" : undefined, + }); } finally { restoreTerminalState("post-setup tui", { resumeStdinIfPaused: true }); } diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index 65bf6b3febc..7fd3de0206d 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -69,7 +69,7 @@ const finalizeSetupWizard = vi.hoisted(() => message = undefined; } - await runTui({ deliver: false, message }); + await runTui({ local: true, deliver: false, message }); return { launchedTui: true }; }), ); @@ -468,6 +468,7 @@ describe("runSetupWizard", () => { expect(runTui).toHaveBeenCalledWith( expect.objectContaining({ + local: true, deliver: false, message: params.expectedMessage, }),