mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:20:43 +00:00
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.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
74
src/tui/tui-last-session.test.ts
Normal file
74
src/tui/tui-last-session.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
98
src/tui/tui-last-session.ts
Normal file
98
src/tui/tui-last-session.ts
Normal file
@@ -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<string, LastSessionRecord>;
|
||||
|
||||
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<LastSessionStore> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ type SessionActionContext = {
|
||||
updateAutocompleteProvider: () => void;
|
||||
setActivityStatus: (text: string) => void;
|
||||
clearLocalRunIds?: () => void;
|
||||
rememberSessionKey?: (sessionKey: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
type SessionInfoDefaults = {
|
||||
@@ -63,6 +64,7 @@ export function createSessionActions(context: SessionActionContext) {
|
||||
updateAutocompleteProvider,
|
||||
setActivityStatus,
|
||||
clearLocalRunIds,
|
||||
rememberSessionKey,
|
||||
} = context;
|
||||
let refreshSessionInfoPromise: Promise<void> = 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)}`);
|
||||
}
|
||||
|
||||
@@ -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<TuiResult> {
|
||||
const agentNames = new Map<string, string>();
|
||||
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<TuiResult> {
|
||||
|
||||
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<TuiResult> {
|
||||
updateAutocompleteProvider,
|
||||
setActivityStatus,
|
||||
clearLocalRunIds,
|
||||
rememberSessionKey: rememberCurrentSessionKey,
|
||||
});
|
||||
const {
|
||||
refreshAgents,
|
||||
@@ -1081,6 +1148,7 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
|
||||
setConnectionStatus(isLocalMode ? "local ready" : "connected");
|
||||
void (async () => {
|
||||
await refreshAgents();
|
||||
await restoreRememberedSession();
|
||||
updateHeader();
|
||||
await loadHistory();
|
||||
setConnectionStatus(
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<unknown>; thinkingLevel?: string };
|
||||
let res: { messages?: Array<unknown>; sessionId?: string; thinkingLevel?: string };
|
||||
for (;;) {
|
||||
try {
|
||||
res = await state.client.request<{ messages?: Array<unknown>; thinkingLevel?: string }>(
|
||||
"chat.history",
|
||||
{
|
||||
sessionKey,
|
||||
limit: 200,
|
||||
},
|
||||
);
|
||||
res = await state.client.request<{
|
||||
messages?: Array<unknown>;
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user