Skip to content

Commit cb9d788

Browse files
authored
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.
1 parent 355680f commit cb9d788

21 files changed

Lines changed: 384 additions & 13 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai
4242
- 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.
4343
- 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.
4444
- 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.
45+
- 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.
4546
- 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.
4647
- 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.
4748
- 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.

apps/macos/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4956,6 +4956,7 @@ public struct ChatHistoryParams: Codable, Sendable {
49564956

49574957
public struct ChatSendParams: Codable, Sendable {
49584958
public let sessionkey: String
4959+
public let sessionid: String?
49594960
public let message: String
49604961
public let thinking: String?
49614962
public let deliver: Bool?
@@ -4971,6 +4972,7 @@ public struct ChatSendParams: Codable, Sendable {
49714972

49724973
public init(
49734974
sessionkey: String,
4975+
sessionid: String?,
49744976
message: String,
49754977
thinking: String?,
49764978
deliver: Bool?,
@@ -4985,6 +4987,7 @@ public struct ChatSendParams: Codable, Sendable {
49854987
idempotencykey: String)
49864988
{
49874989
self.sessionkey = sessionkey
4990+
self.sessionid = sessionid
49884991
self.message = message
49894992
self.thinking = thinking
49904993
self.deliver = deliver
@@ -5001,6 +5004,7 @@ public struct ChatSendParams: Codable, Sendable {
50015004

50025005
private enum CodingKeys: String, CodingKey {
50035006
case sessionkey = "sessionKey"
5007+
case sessionid = "sessionId"
50045008
case message
50055009
case thinking
50065010
case deliver

apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4956,6 +4956,7 @@ public struct ChatHistoryParams: Codable, Sendable {
49564956

49574957
public struct ChatSendParams: Codable, Sendable {
49584958
public let sessionkey: String
4959+
public let sessionid: String?
49594960
public let message: String
49604961
public let thinking: String?
49614962
public let deliver: Bool?
@@ -4971,6 +4972,7 @@ public struct ChatSendParams: Codable, Sendable {
49714972

49724973
public init(
49734974
sessionkey: String,
4975+
sessionid: String?,
49744976
message: String,
49754977
thinking: String?,
49764978
deliver: Bool?,
@@ -4985,6 +4987,7 @@ public struct ChatSendParams: Codable, Sendable {
49854987
idempotencykey: String)
49864988
{
49874989
self.sessionkey = sessionkey
4990+
self.sessionid = sessionid
49884991
self.message = message
49894992
self.thinking = thinking
49904993
self.deliver = deliver
@@ -5001,6 +5004,7 @@ public struct ChatSendParams: Codable, Sendable {
50015004

50025005
private enum CodingKeys: String, CodingKey {
50035006
case sessionkey = "sessionKey"
5007+
case sessionid = "sessionId"
50045008
case message
50055009
case thinking
50065010
case deliver

docs/web/tui.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ Notes:
6868
- `per-sender` (default): each agent has many sessions.
6969
- `global`: the TUI always uses the `global` session (the picker may be empty).
7070
- The current agent + session are always visible in the footer.
71+
- 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.
7172

7273
## Sending + delivery
7374

docs/web/webchat.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket.
2525
- The UI connects to the Gateway WebSocket and uses `chat.history`, `chat.send`, and `chat.inject`.
2626
- `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]`.
2727
- `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.
28+
- 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.
2829
- 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.
2930
- `chat.history` is also display-normalized: runtime-only OpenClaw context,
3031
inbound envelope wrappers, inline delivery directive tags

src/gateway/protocol/schema/logs-chat.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export const ChatHistoryParamsSchema = Type.Object(
3535
export const ChatSendParamsSchema = Type.Object(
3636
{
3737
sessionKey: ChatSendSessionKeyString,
38+
sessionId: Type.Optional(NonEmptyString),
3839
message: Type.String(),
3940
thinking: Type.Optional(Type.String()),
4041
deliver: Type.Optional(Type.Boolean()),

src/gateway/server-methods/chat.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1830,6 +1830,7 @@ export const chatHandlers: GatewayRequestHandlers = {
18301830
}
18311831
const p = params as {
18321832
sessionKey: string;
1833+
sessionId?: string;
18331834
message: string;
18341835
thinking?: string;
18351836
deliver?: boolean;
@@ -1904,6 +1905,8 @@ export const chatHandlers: GatewayRequestHandlers = {
19041905
}
19051906
const rawSessionKey = p.sessionKey;
19061907
const { cfg, entry, canonicalKey: sessionKey } = loadSessionEntry(rawSessionKey);
1908+
const requestedSessionId = normalizeOptionalText(p.sessionId);
1909+
const backingSessionId = entry?.sessionId ?? requestedSessionId;
19071910
const deletedAgentId = resolveDeletedAgentIdFromSessionKey(cfg, sessionKey);
19081911
if (deletedAgentId !== null) {
19091912
respond(
@@ -2049,7 +2052,7 @@ export const chatHandlers: GatewayRequestHandlers = {
20492052
const activeRunAbort = registerChatAbortController({
20502053
chatAbortControllers: context.chatAbortControllers,
20512054
runId: clientRunId,
2052-
sessionId: entry?.sessionId ?? clientRunId,
2055+
sessionId: backingSessionId ?? clientRunId,
20532056
sessionKey: rawSessionKey,
20542057
timeoutMs,
20552058
now,
@@ -2167,7 +2170,7 @@ export const chatHandlers: GatewayRequestHandlers = {
21672170
}
21682171
userTranscriptUpdatePromise = (async () => {
21692172
const { storePath: latestStorePath, entry: latestEntry } = loadSessionEntry(sessionKey);
2170-
const resolvedSessionId = latestEntry?.sessionId ?? entry?.sessionId;
2173+
const resolvedSessionId = latestEntry?.sessionId ?? backingSessionId;
21712174
if (!resolvedSessionId) {
21722175
return;
21732176
}
@@ -2199,7 +2202,7 @@ export const chatHandlers: GatewayRequestHandlers = {
21992202
return;
22002203
}
22012204
const { storePath: latestStorePath, entry: latestEntry } = loadSessionEntry(sessionKey);
2202-
const resolvedSessionId = latestEntry?.sessionId ?? entry?.sessionId;
2205+
const resolvedSessionId = latestEntry?.sessionId ?? backingSessionId;
22032206
if (!resolvedSessionId) {
22042207
return;
22052208
}
@@ -2226,7 +2229,7 @@ export const chatHandlers: GatewayRequestHandlers = {
22262229
}
22272230
const transcriptPayload = stripVisibleTextFromTtsSupplement(payload);
22282231
const { storePath: latestStorePath, entry: latestEntry } = loadSessionEntry(sessionKey);
2229-
const sessionId = latestEntry?.sessionId ?? entry?.sessionId ?? clientRunId;
2232+
const sessionId = latestEntry?.sessionId ?? backingSessionId ?? clientRunId;
22302233
const resolvedTranscriptPath = resolveTranscriptPath({
22312234
sessionId,
22322235
storePath: latestStorePath,
@@ -2400,7 +2403,7 @@ export const chatHandlers: GatewayRequestHandlers = {
24002403
.map((entry) => entry.payload);
24012404
const { storePath: latestStorePath, entry: latestEntry } =
24022405
loadSessionEntry(sessionKey);
2403-
const sessionId = latestEntry?.sessionId ?? entry?.sessionId ?? clientRunId;
2406+
const sessionId = latestEntry?.sessionId ?? backingSessionId ?? clientRunId;
24042407
const resolvedTranscriptPath = resolveTranscriptPath({
24052408
sessionId,
24062409
storePath: latestStorePath,

src/gateway/server.chat.gateway-server-chat.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,29 @@ describe("gateway server chat", () => {
609609
}
610610
});
611611

612+
test("chat.send accepts the backing session id returned by chat.history", async () => {
613+
await withMainSessionStore(async () => {
614+
const historyRes = await rpcReq<{ sessionId?: string }>(ws, "chat.history", {
615+
sessionKey: "main",
616+
});
617+
expect(historyRes.ok).toBe(true);
618+
const sessionId = historyRes.payload?.sessionId;
619+
expect(sessionId).toBe("sess-main");
620+
621+
const runId = "idem-chat-send-history-session-id";
622+
const sendRes = await rpcReq(ws, "chat.send", {
623+
sessionKey: "main",
624+
sessionId,
625+
message: "/context list",
626+
idempotencyKey: runId,
627+
});
628+
expect(sendRes.ok).toBe(true);
629+
expect(sendRes.payload?.status).toBe("started");
630+
631+
await waitForAgentRunOk(runId);
632+
});
633+
});
634+
612635
test("chat.history hides assistant NO_REPLY-only entries", async () => {
613636
const historyMessages = await loadChatHistoryWithMessages(buildNoReplyHistoryFixture());
614637
const textValues = collectHistoryTextValues(historyMessages);

src/tui/gateway-chat.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ export class GatewayChatClient implements TuiBackend {
182182
const runId = opts.runId ?? randomUUID();
183183
await this.client.request("chat.send", {
184184
sessionKey: opts.sessionKey,
185+
...(opts.sessionId ? { sessionId: opts.sessionId } : {}),
185186
message: opts.message,
186187
thinking: opts.thinking,
187188
deliver: opts.deliver,

src/tui/tui-backend.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { ResponseUsageMode, SessionInfo, SessionScope } from "./tui-types.j
77

88
export type ChatSendOptions = {
99
sessionKey: string;
10+
sessionId?: string | null;
1011
message: string;
1112
thinking?: string;
1213
deliver?: boolean;

0 commit comments

Comments
 (0)