From cb9d7884cca3a8234637d6ee82e66a50d86c42a9 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 2 May 2026 00:08:01 -0500 Subject: [PATCH] fix(ui): preserve local session continuity (#75948) Fixes #63195. Closes #68162. Closes #73546. - Keep Control UI chat sends bound to the history-backed session id across reconnects. - Accept chat.send sessionId at the gateway/protocol boundary and update generated Swift models. - Resume the last selected TUI session for the same gateway/agent/scope when still present. Validated by exact-SHA CI on PR #75948. --- CHANGELOG.md | 1 + .../OpenClawProtocol/GatewayModels.swift | 4 + .../OpenClawProtocol/GatewayModels.swift | 4 + docs/web/tui.md | 1 + docs/web/webchat.md | 1 + src/gateway/protocol/schema/logs-chat.ts | 1 + src/gateway/server-methods/chat.ts | 13 ++- .../server.chat.gateway-server-chat.test.ts | 23 +++++ src/tui/gateway-chat.ts | 1 + src/tui/tui-backend.ts | 1 + src/tui/tui-command-handlers.test.ts | 18 ++++ src/tui/tui-command-handlers.ts | 1 + src/tui/tui-last-session.test.ts | 74 ++++++++++++++ src/tui/tui-last-session.ts | 98 +++++++++++++++++++ src/tui/tui-session-actions.test.ts | 30 ++++++ src/tui/tui-session-actions.ts | 3 + src/tui/tui.ts | 68 +++++++++++++ ui/src/ui/app-render.helpers.ts | 1 + ui/src/ui/app.ts | 1 + ui/src/ui/controllers/chat.test.ts | 28 ++++++ ui/src/ui/controllers/chat.ts | 25 +++-- 21 files changed, 384 insertions(+), 13 deletions(-) create mode 100644 src/tui/tui-last-session.test.ts create mode 100644 src/tui/tui-last-session.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 916c4cc26f7..e3a95b12c94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai - Discord/threads: return the created thread as partial success when the follow-up initial message fails, so agents do not retry thread creation and create empty duplicate threads. Fixes #48450. Thanks @dahifi. - Discord/components: consume every button or select in a non-reusable component message after the first authorized click, so single-use panels cannot fire sibling callbacks. Fixes #54227. Thanks @fujiwarakasei. - macOS/config: preserve existing `gateway.auth` and unrelated config keys during app fallback writes, so dashboard or Talk settings changes cannot strand Control UI clients by dropping persisted auth. Fixes #75631. Thanks @Fuma2013. +- Control UI/TUI: keep reconnecting chat sends bound to the same backing session id and let TUI relaunches resume the last selected session, avoiding silent fresh sessions after refresh, reconnect, or terminal restart. Fixes #63195, #68162, and #73546. Thanks @bond260312-cmyk, @zhong18804784882, and @mtuwei. - Discord/reactions: skip reaction listener registration when DMs and group DMs are disabled and every configured guild has `reactionNotifications: "off"`, avoiding needless reaction-event queue work. Fixes #47516. Thanks @x4v13r1120. - CLI sessions: preserve explicit manual-attach reuse bindings so trusted CLI sessions are not invalidated on the first turn when auth, prompt, or MCP fingerprints drift. Fixes #75849. Thanks @alfredjbclaw. - Telegram/streaming: keep partial preview streaming enabled for plain reply-to replies, disabling drafts only for real native quote excerpts that require Telegram quote parameters. Fixes #73505. Thanks @choury. diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index 3a29531e182..94a10603763 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -4956,6 +4956,7 @@ public struct ChatHistoryParams: Codable, Sendable { public struct ChatSendParams: Codable, Sendable { public let sessionkey: String + public let sessionid: String? public let message: String public let thinking: String? public let deliver: Bool? @@ -4971,6 +4972,7 @@ public struct ChatSendParams: Codable, Sendable { public init( sessionkey: String, + sessionid: String?, message: String, thinking: String?, deliver: Bool?, @@ -4985,6 +4987,7 @@ public struct ChatSendParams: Codable, Sendable { idempotencykey: String) { self.sessionkey = sessionkey + self.sessionid = sessionid self.message = message self.thinking = thinking self.deliver = deliver @@ -5001,6 +5004,7 @@ public struct ChatSendParams: Codable, Sendable { private enum CodingKeys: String, CodingKey { case sessionkey = "sessionKey" + case sessionid = "sessionId" case message case thinking case deliver diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 3a29531e182..94a10603763 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -4956,6 +4956,7 @@ public struct ChatHistoryParams: Codable, Sendable { public struct ChatSendParams: Codable, Sendable { public let sessionkey: String + public let sessionid: String? public let message: String public let thinking: String? public let deliver: Bool? @@ -4971,6 +4972,7 @@ public struct ChatSendParams: Codable, Sendable { public init( sessionkey: String, + sessionid: String?, message: String, thinking: String?, deliver: Bool?, @@ -4985,6 +4987,7 @@ public struct ChatSendParams: Codable, Sendable { idempotencykey: String) { self.sessionkey = sessionkey + self.sessionid = sessionid self.message = message self.thinking = thinking self.deliver = deliver @@ -5001,6 +5004,7 @@ public struct ChatSendParams: Codable, Sendable { private enum CodingKeys: String, CodingKey { case sessionkey = "sessionKey" + case sessionid = "sessionId" case message case thinking case deliver diff --git a/docs/web/tui.md b/docs/web/tui.md index 540c349d837..f5254b68dd9 100644 --- a/docs/web/tui.md +++ b/docs/web/tui.md @@ -68,6 +68,7 @@ Notes: - `per-sender` (default): each agent has many sessions. - `global`: the TUI always uses the `global` session (the picker may be empty). - The current agent + session are always visible in the footer. +- When started without `--session`, gateway-mode TUI resumes the last selected session for the same gateway, agent, and session scope if that session still exists. Passing `--session`, `/session`, `/new`, or `/reset` remains explicit. ## Sending + delivery diff --git a/docs/web/webchat.md b/docs/web/webchat.md index e3b78fc314a..0499f607e09 100644 --- a/docs/web/webchat.md +++ b/docs/web/webchat.md @@ -25,6 +25,7 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket. - The UI connects to the Gateway WebSocket and uses `chat.history`, `chat.send`, and `chat.inject`. - `chat.history` is bounded for stability: Gateway may truncate long text fields, omit heavy metadata, and replace oversized entries with `[chat.history omitted: message too large]`. - `chat.history` follows the active transcript branch for modern append-only session files, so abandoned rewrite branches and superseded prompt copies are not rendered in WebChat. +- Control UI remembers the backing Gateway `sessionId` returned by `chat.history` and includes it on follow-up `chat.send` calls, so reconnects and page refreshes continue the same stored conversation unless the user starts or resets a session. - Control UI coalesces duplicate in-flight submits for the same session, message, and attachments before generating a new `chat.send` run id; the Gateway still dedupes repeated requests that reuse the same idempotency key. - `chat.history` is also display-normalized: runtime-only OpenClaw context, inbound envelope wrappers, inline delivery directive tags diff --git a/src/gateway/protocol/schema/logs-chat.ts b/src/gateway/protocol/schema/logs-chat.ts index ffba8ce86ef..01468e0c230 100644 --- a/src/gateway/protocol/schema/logs-chat.ts +++ b/src/gateway/protocol/schema/logs-chat.ts @@ -35,6 +35,7 @@ export const ChatHistoryParamsSchema = Type.Object( export const ChatSendParamsSchema = Type.Object( { sessionKey: ChatSendSessionKeyString, + sessionId: Type.Optional(NonEmptyString), message: Type.String(), thinking: Type.Optional(Type.String()), deliver: Type.Optional(Type.Boolean()), diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index d9eb912fc5b..697e1081c7c 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -1830,6 +1830,7 @@ export const chatHandlers: GatewayRequestHandlers = { } const p = params as { sessionKey: string; + sessionId?: string; message: string; thinking?: string; deliver?: boolean; @@ -1904,6 +1905,8 @@ export const chatHandlers: GatewayRequestHandlers = { } const rawSessionKey = p.sessionKey; const { cfg, entry, canonicalKey: sessionKey } = loadSessionEntry(rawSessionKey); + const requestedSessionId = normalizeOptionalText(p.sessionId); + const backingSessionId = entry?.sessionId ?? requestedSessionId; const deletedAgentId = resolveDeletedAgentIdFromSessionKey(cfg, sessionKey); if (deletedAgentId !== null) { respond( @@ -2049,7 +2052,7 @@ export const chatHandlers: GatewayRequestHandlers = { const activeRunAbort = registerChatAbortController({ chatAbortControllers: context.chatAbortControllers, runId: clientRunId, - sessionId: entry?.sessionId ?? clientRunId, + sessionId: backingSessionId ?? clientRunId, sessionKey: rawSessionKey, timeoutMs, now, @@ -2167,7 +2170,7 @@ export const chatHandlers: GatewayRequestHandlers = { } userTranscriptUpdatePromise = (async () => { const { storePath: latestStorePath, entry: latestEntry } = loadSessionEntry(sessionKey); - const resolvedSessionId = latestEntry?.sessionId ?? entry?.sessionId; + const resolvedSessionId = latestEntry?.sessionId ?? backingSessionId; if (!resolvedSessionId) { return; } @@ -2199,7 +2202,7 @@ export const chatHandlers: GatewayRequestHandlers = { return; } const { storePath: latestStorePath, entry: latestEntry } = loadSessionEntry(sessionKey); - const resolvedSessionId = latestEntry?.sessionId ?? entry?.sessionId; + const resolvedSessionId = latestEntry?.sessionId ?? backingSessionId; if (!resolvedSessionId) { return; } @@ -2226,7 +2229,7 @@ export const chatHandlers: GatewayRequestHandlers = { } const transcriptPayload = stripVisibleTextFromTtsSupplement(payload); const { storePath: latestStorePath, entry: latestEntry } = loadSessionEntry(sessionKey); - const sessionId = latestEntry?.sessionId ?? entry?.sessionId ?? clientRunId; + const sessionId = latestEntry?.sessionId ?? backingSessionId ?? clientRunId; const resolvedTranscriptPath = resolveTranscriptPath({ sessionId, storePath: latestStorePath, @@ -2400,7 +2403,7 @@ export const chatHandlers: GatewayRequestHandlers = { .map((entry) => entry.payload); const { storePath: latestStorePath, entry: latestEntry } = loadSessionEntry(sessionKey); - const sessionId = latestEntry?.sessionId ?? entry?.sessionId ?? clientRunId; + const sessionId = latestEntry?.sessionId ?? backingSessionId ?? clientRunId; const resolvedTranscriptPath = resolveTranscriptPath({ sessionId, storePath: latestStorePath, diff --git a/src/gateway/server.chat.gateway-server-chat.test.ts b/src/gateway/server.chat.gateway-server-chat.test.ts index d988c13da80..0afbe341934 100644 --- a/src/gateway/server.chat.gateway-server-chat.test.ts +++ b/src/gateway/server.chat.gateway-server-chat.test.ts @@ -609,6 +609,29 @@ describe("gateway server chat", () => { } }); + test("chat.send accepts the backing session id returned by chat.history", async () => { + await withMainSessionStore(async () => { + const historyRes = await rpcReq<{ sessionId?: string }>(ws, "chat.history", { + sessionKey: "main", + }); + expect(historyRes.ok).toBe(true); + const sessionId = historyRes.payload?.sessionId; + expect(sessionId).toBe("sess-main"); + + const runId = "idem-chat-send-history-session-id"; + const sendRes = await rpcReq(ws, "chat.send", { + sessionKey: "main", + sessionId, + message: "/context list", + idempotencyKey: runId, + }); + expect(sendRes.ok).toBe(true); + expect(sendRes.payload?.status).toBe("started"); + + await waitForAgentRunOk(runId); + }); + }); + test("chat.history hides assistant NO_REPLY-only entries", async () => { const historyMessages = await loadChatHistoryWithMessages(buildNoReplyHistoryFixture()); const textValues = collectHistoryTextValues(historyMessages); diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index bbd302af72e..1a0cc38a13b 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -182,6 +182,7 @@ export class GatewayChatClient implements TuiBackend { const runId = opts.runId ?? randomUUID(); await this.client.request("chat.send", { sessionKey: opts.sessionKey, + ...(opts.sessionId ? { sessionId: opts.sessionId } : {}), message: opts.message, thinking: opts.thinking, deliver: opts.deliver, diff --git a/src/tui/tui-backend.ts b/src/tui/tui-backend.ts index 660f8eb257d..81ca84c1cb6 100644 --- a/src/tui/tui-backend.ts +++ b/src/tui/tui-backend.ts @@ -7,6 +7,7 @@ import type { ResponseUsageMode, SessionInfo, SessionScope } from "./tui-types.j export type ChatSendOptions = { sessionKey: string; + sessionId?: string | null; message: string; thinking?: string; deliver?: boolean; diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts index 587fc8ab1a4..6c0c992609b 100644 --- a/src/tui/tui-command-handlers.test.ts +++ b/src/tui/tui-command-handlers.test.ts @@ -28,6 +28,7 @@ function createHarness(params?: { activeChatRunId?: string | null; pendingOptimisticUserMessage?: boolean; opts?: { local?: boolean }; + currentSessionId?: string | null; }) { const sendChat = params?.sendChat ?? vi.fn().mockResolvedValue({ runId: "r1" }); const getGatewayStatus = params?.getGatewayStatus ?? vi.fn().mockResolvedValue({}); @@ -55,6 +56,7 @@ function createHarness(params?: { const state = { currentAgentId: "main", currentSessionKey: "agent:main:main", + currentSessionId: params?.currentSessionId ?? null, activeChatRunId: params?.activeChatRunId ?? null, pendingOptimisticUserMessage: params?.pendingOptimisticUserMessage ?? false, isConnected: params?.isConnected ?? true, @@ -155,6 +157,22 @@ describe("tui command handlers", () => { expect(requestRender).toHaveBeenCalled(); }); + it("passes the current backing session id when sending to the gateway", async () => { + const { handleCommand, sendChat } = createHarness({ + currentSessionId: "session-before-relaunch", + }); + + await handleCommand("/status"); + + expect(sendChat).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:main:main", + sessionId: "session-before-relaunch", + message: "/status", + }), + ); + }); + it("opens a context mode selector for /context without sending immediately", async () => { const { handleCommand, sendChat, openOverlay } = createHarness(); diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index f177112090d..afc8f52a1db 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -634,6 +634,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { tui.requestRender(); await client.sendChat({ sessionKey: state.currentSessionKey, + sessionId: state.currentSessionId, message: text, thinking: opts.thinking, deliver: deliverDefault, diff --git a/src/tui/tui-last-session.test.ts b/src/tui/tui-last-session.test.ts new file mode 100644 index 00000000000..8799983ce4b --- /dev/null +++ b/src/tui/tui-last-session.test.ts @@ -0,0 +1,74 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + buildTuiLastSessionScopeKey, + readTuiLastSessionKey, + resolveRememberedTuiSessionKey, + resolveTuiLastSessionStatePath, + writeTuiLastSessionKey, +} from "./tui-last-session.js"; + +const tempDirs: string[] = []; + +async function makeTempStateDir() { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-tui-last-session-")); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe("tui last session state", () => { + it("persists the last session under a scoped hashed key", async () => { + const stateDir = await makeTempStateDir(); + const scopeKey = buildTuiLastSessionScopeKey({ + connectionUrl: "ws://127.0.0.1:18789", + agentId: "Main", + sessionScope: "per-sender", + }); + + await writeTuiLastSessionKey({ + scopeKey, + sessionKey: "agent:main:tui-123", + stateDir, + }); + + await expect(readTuiLastSessionKey({ scopeKey, stateDir })).resolves.toBe("agent:main:tui-123"); + const raw = await fs.readFile(resolveTuiLastSessionStatePath(stateDir), "utf8"); + expect(raw).not.toContain("127.0.0.1"); + }); + + it("restores only a remembered session that still belongs to the current agent", () => { + const sessions = [ + { key: "agent:main:main" }, + { key: "agent:main:tui-123" }, + { key: "agent:ops:tui-999" }, + ]; + + expect( + resolveRememberedTuiSessionKey({ + rememberedKey: "agent:main:tui-123", + currentAgentId: "main", + sessions, + }), + ).toBe("agent:main:tui-123"); + expect( + resolveRememberedTuiSessionKey({ + rememberedKey: "agent:ops:tui-999", + currentAgentId: "main", + sessions, + }), + ).toBeNull(); + expect( + resolveRememberedTuiSessionKey({ + rememberedKey: "agent:main:missing", + currentAgentId: "main", + sessions, + }), + ).toBeNull(); + }); +}); diff --git a/src/tui/tui-last-session.ts b/src/tui/tui-last-session.ts new file mode 100644 index 00000000000..caa94c9bb80 --- /dev/null +++ b/src/tui/tui-last-session.ts @@ -0,0 +1,98 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { resolveStateDir } from "../config/paths.js"; +import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; +import type { TuiSessionList } from "./tui-backend.js"; +import type { SessionScope } from "./tui-types.js"; + +type LastSessionRecord = { + sessionKey: string; + updatedAt: number; +}; + +type LastSessionStore = Record; + +export function resolveTuiLastSessionStatePath(stateDir = resolveStateDir()): string { + return path.join(stateDir, "tui", "last-session.json"); +} + +export function buildTuiLastSessionScopeKey(params: { + connectionUrl: string; + agentId: string; + sessionScope: SessionScope; +}): string { + const agentId = normalizeAgentId(params.agentId); + const connectionUrl = params.connectionUrl.trim() || "local"; + return createHash("sha256") + .update(`${params.sessionScope}\n${agentId}\n${connectionUrl}`) + .digest("hex") + .slice(0, 32); +} + +async function readStore(filePath: string): Promise { + try { + const raw = await fs.readFile(filePath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as LastSessionStore) + : {}; + } catch { + return {}; + } +} + +export async function readTuiLastSessionKey(params: { + scopeKey: string; + stateDir?: string; +}): Promise { + const store = await readStore(resolveTuiLastSessionStatePath(params.stateDir)); + const value = store[params.scopeKey]?.sessionKey; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +export async function writeTuiLastSessionKey(params: { + scopeKey: string; + sessionKey: string; + stateDir?: string; +}): Promise { + const sessionKey = params.sessionKey.trim(); + if (!sessionKey || sessionKey === "unknown") { + return; + } + const filePath = resolveTuiLastSessionStatePath(params.stateDir); + const store = await readStore(filePath); + store[params.scopeKey] = { + sessionKey, + updatedAt: Date.now(), + }; + await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 }); + await fs.writeFile(filePath, `${JSON.stringify(store, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); +} + +export function resolveRememberedTuiSessionKey(params: { + rememberedKey: string | null | undefined; + currentAgentId: string; + sessions: TuiSessionList["sessions"]; +}): string | null { + const rememberedKey = params.rememberedKey?.trim(); + if (!rememberedKey) { + return null; + } + const currentAgentId = normalizeAgentId(params.currentAgentId); + const parsed = parseAgentSessionKey(rememberedKey); + if (parsed && normalizeAgentId(parsed.agentId) !== currentAgentId) { + return null; + } + const rememberedRest = parsed?.rest ?? rememberedKey; + const match = params.sessions.find((session) => { + if (session.key === rememberedKey) { + return true; + } + return parseAgentSessionKey(session.key)?.rest === rememberedRest; + }); + return match?.key ?? null; +} diff --git a/src/tui/tui-session-actions.test.ts b/src/tui/tui-session-actions.test.ts index a76333e6d90..70db6de65a3 100644 --- a/src/tui/tui-session-actions.test.ts +++ b/src/tui/tui-session-actions.test.ts @@ -337,4 +337,34 @@ describe("tui session actions", () => { expect(setActivityStatus).toHaveBeenCalledWith("idle"); expect(state.activeChatRunId).toBeNull(); }); + + it("remembers the selected session after history loads", async () => { + const listSessions = vi.fn().mockResolvedValue({ + ts: Date.now(), + path: "/tmp/sessions.json", + count: 1, + defaults: {}, + sessions: [{ key: "agent:main:main", sessionId: "session-main" }], + }); + const loadHistory = vi.fn().mockResolvedValue({ + sessionId: "session-main", + messages: [], + }); + const rememberSessionKey = vi.fn(); + const state = createBaseState(); + + const { loadHistory: runLoadHistory } = createTestSessionActions({ + client: { + listSessions, + loadHistory, + } as unknown as TuiBackend, + state, + rememberSessionKey, + }); + + await runLoadHistory(); + + expect(state.currentSessionId).toBe("session-main"); + expect(rememberSessionKey).toHaveBeenCalledWith("agent:main:main"); + }); }); diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index 65c4f741700..5967a2876b5 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -32,6 +32,7 @@ type SessionActionContext = { updateAutocompleteProvider: () => void; setActivityStatus: (text: string) => void; clearLocalRunIds?: () => void; + rememberSessionKey?: (sessionKey: string) => void | Promise; }; type SessionInfoDefaults = { @@ -63,6 +64,7 @@ export function createSessionActions(context: SessionActionContext) { updateAutocompleteProvider, setActivityStatus, clearLocalRunIds, + rememberSessionKey, } = context; let refreshSessionInfoPromise: Promise = Promise.resolve(); let lastSessionDefaults: SessionInfoDefaults | null = null; @@ -362,6 +364,7 @@ export function createSessionActions(context: SessionActionContext) { } } state.historyLoaded = true; + void rememberSessionKey?.(state.currentSessionKey); } catch (err) { chatLog.addSystem(`history failed: ${String(err)}`); } diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 88fc58feda8..b9836c0aea2 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -33,6 +33,12 @@ 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"; +import { + buildTuiLastSessionScopeKey, + readTuiLastSessionKey, + resolveRememberedTuiSessionKey, + writeTuiLastSessionKey, +} from "./tui-last-session.js"; import { createLocalShellRunner } from "./tui-local-shell.js"; import { createOverlayHandlers } from "./tui-overlays.js"; import { createSessionActions } from "./tui-session-actions.js"; @@ -307,6 +313,7 @@ export async function runTui(opts: RunTuiOptions): Promise { const agentNames = new Map(); let currentSessionKey = ""; let initialSessionApplied = false; + let rememberedSessionApplied = false; let currentSessionId: string | null = null; let activeChatRunId: string | null = null; let pendingOptimisticUserMessage = false; @@ -583,6 +590,65 @@ export async function runTui(opts: RunTuiOptions): Promise { currentSessionKey = resolveSessionKey(initialSessionInput); + const buildLastSessionScopeKeyFor = (sessionKey = currentSessionKey) => { + const parsed = parseAgentSessionKey(sessionKey); + return buildTuiLastSessionScopeKey({ + connectionUrl: client.connection.url, + agentId: parsed?.agentId ?? currentAgentId, + sessionScope, + }); + }; + + const rememberCurrentSessionKey = (sessionKey: string) => { + const trimmed = sessionKey.trim(); + if (!trimmed || trimmed === "unknown") { + return; + } + void writeTuiLastSessionKey({ + scopeKey: buildLastSessionScopeKeyFor(trimmed), + sessionKey: trimmed, + }).catch(() => undefined); + }; + + const restoreRememberedSession = async () => { + if (initialSessionInput || rememberedSessionApplied) { + return; + } + rememberedSessionApplied = true; + const remembered = await readTuiLastSessionKey({ + scopeKey: buildLastSessionScopeKeyFor(), + }); + const rememberedKey = remembered ? resolveSessionKey(remembered) : null; + if (!rememberedKey || rememberedKey === currentSessionKey) { + return; + } + const rememberedAgent = parseAgentSessionKey(rememberedKey)?.agentId; + if (rememberedAgent && normalizeAgentId(rememberedAgent) !== currentAgentId) { + return; + } + const sessions = await client + .listSessions({ + includeGlobal: false, + includeUnknown: false, + agentId: currentAgentId, + }) + .catch(() => null); + if (!sessions) { + return; + } + const restored = resolveRememberedTuiSessionKey({ + rememberedKey, + currentAgentId, + sessions: sessions.sessions, + }); + if (!restored || restored === currentSessionKey) { + return; + } + currentSessionKey = restored; + updateHeader(); + updateFooter(); + }; + const updateHeader = () => { const sessionLabel = formatSessionKey(currentSessionKey); const agentLabel = formatAgentLabel(currentAgentId); @@ -890,6 +956,7 @@ export async function runTui(opts: RunTuiOptions): Promise { updateAutocompleteProvider, setActivityStatus, clearLocalRunIds, + rememberSessionKey: rememberCurrentSessionKey, }); const { refreshAgents, @@ -1081,6 +1148,7 @@ export async function runTui(opts: RunTuiOptions): Promise { setConnectionStatus(isLocalMode ? "local ready" : "connected"); void (async () => { await refreshAgents(); + await restoreRememberedSession(); updateHeader(); await loadHistory(); setConnectionStatus( diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index f40824a2678..9c6eb4d1ca2 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -89,6 +89,7 @@ function resetChatStateForSessionSwitch(state: AppViewState, sessionKey: string) const previousSessionKey = state.sessionKey; saveChatQueueForSession(state, previousSessionKey); state.sessionKey = sessionKey; + (state as unknown as { currentSessionId?: string | null }).currentSessionId = null; state.chatMessage = ""; state.chatAttachments = []; state.chatMessages = []; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index b53c5699306..68f3dc26cc0 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -191,6 +191,7 @@ export class OpenClawApp extends LitElement { @state() serverVersion: string | null = null; @state() sessionKey = this.settings.sessionKey; + currentSessionId: string | null = null; @state() chatLoading = false; @state() chatSending = false; @state() chatMessage = ""; diff --git a/ui/src/ui/controllers/chat.test.ts b/ui/src/ui/controllers/chat.test.ts index f5d185351f0..0115c549614 100644 --- a/ui/src/ui/controllers/chat.test.ts +++ b/ui/src/ui/controllers/chat.test.ts @@ -820,6 +820,34 @@ describe("sendChatMessage", () => { expect(state.chatMessages).toHaveLength(1); }); + it("passes the backing session id from history when sending after reconnect", async () => { + const request = vi + .fn() + .mockResolvedValueOnce({ + sessionId: "session-before-reconnect", + messages: [], + }) + .mockResolvedValueOnce({ runId: "run-1", status: "started" }); + const state = createState({ + connected: true, + client: { request } as unknown as ChatState["client"], + }); + + await loadChatHistory(state); + const result = await sendChatMessage(state, "continue"); + + expect(result).toEqual(expect.any(String)); + expect(state.currentSessionId).toBe("session-before-reconnect"); + expect(request).toHaveBeenLastCalledWith( + "chat.send", + expect.objectContaining({ + sessionKey: "main", + sessionId: "session-before-reconnect", + message: "continue", + }), + ); + }); + it("serializes non-image chat attachments as files", async () => { const request = vi.fn().mockResolvedValue({ runId: "run-1", status: "started" }); const state = createState({ diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index e63080a3672..a7673ac0d0f 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -350,6 +350,7 @@ export type ChatState = { client: GatewayBrowserClient | null; connected: boolean; sessionKey: string; + currentSessionId?: string | null; chatLoading: boolean; chatMessages: unknown[]; chatThinkingLevel: string | null; @@ -396,16 +397,17 @@ export async function loadChatHistory(state: ChatState) { state.chatLoading = true; state.lastError = null; try { - let res: { messages?: Array; thinkingLevel?: string }; + let res: { messages?: Array; sessionId?: string; thinkingLevel?: string }; for (;;) { try { - res = await state.client.request<{ messages?: Array; thinkingLevel?: string }>( - "chat.history", - { - sessionKey, - limit: 200, - }, - ); + res = await state.client.request<{ + messages?: Array; + sessionId?: string; + thinkingLevel?: string; + }>("chat.history", { + sessionKey, + limit: 200, + }); break; } catch (err) { if (!shouldApplyChatHistoryResult(state, requestVersion, sessionKey)) { @@ -429,6 +431,8 @@ export async function loadChatHistory(state: ChatState) { const messages = Array.isArray(res.messages) ? res.messages : []; const visibleMessages = messages.filter((message) => !shouldHideHistoryMessage(message)); state.chatMessages = preserveOptimisticTailMessages(visibleMessages, previousMessages); + state.currentSessionId = + typeof res.sessionId === "string" && res.sessionId.trim() ? res.sessionId : null; state.chatThinkingLevel = res.thinkingLevel ?? null; // Clear all streaming state — history includes tool results and text // inline, so keeping streaming artifacts would cause duplicates. @@ -486,8 +490,13 @@ async function requestChatSend( state: ChatState, params: { message: string; attachments?: ChatAttachment[]; runId: string }, ) { + const sessionId = + typeof state.currentSessionId === "string" && state.currentSessionId.trim() + ? state.currentSessionId.trim() + : undefined; await state.client!.request("chat.send", { sessionKey: state.sessionKey, + ...(sessionId ? { sessionId } : {}), message: params.message, deliver: false, idempotencyKey: params.runId,