fix: keep shared web and TUI chats synchronized under load (#115191)

* fix: keep shared web and TUI chats synchronized under load

* test: satisfy strict shared-chat ordering types
This commit is contained in:
Peter Steinberger
2026-07-28 09:39:22 -04:00
committed by GitHub
parent 8eaa4d6d70
commit fda8391fd9
21 changed files with 1340 additions and 131 deletions

View File

@@ -406,6 +406,32 @@ describe("gateway broadcaster", () => {
expect(getBufferedAmount("c-admin")).toBeUndefined();
});
it("closes a slow authoritative-session subscriber while delivering to healthy clients", () => {
const slowSocket = makeRecordingSocket();
slowSocket.bufferedAmount = MAX_BUFFERED_BYTES + 1;
const healthySocket = makeRecordingSocket();
const clients = makeOperatorWsClients([
{ connId: "slow-session", socket: slowSocket, scopes: ["operator.read"] },
{ connId: "healthy-session", socket: healthySocket, scopes: ["operator.read"] },
]);
const { broadcastToConnIds } = createGatewayBroadcaster({ clients });
const payload = {
sessionKey: "agent:main:main",
messageId: "durable-user-1",
messageSeq: 1,
message: {
role: "user",
content: [{ type: "text", text: "shared durable prompt" }],
},
};
broadcastToConnIds("session.message", payload, new Set(["slow-session", "healthy-session"]));
expect(slowSocket.close).toHaveBeenCalledWith(1008, "slow consumer");
expect(slowSocket.send).not.toHaveBeenCalled();
expect(healthySocket.sent).toEqual([{ type: "event", event: "session.message", payload }]);
});
it("keeps workers outside all generic and targeted gateway broadcasts", () => {
const workerSocket = makeRecordingSocket();
const worker = makeGatewayWsClient("c-worker", workerSocket, {

View File

@@ -103,6 +103,29 @@ describe("createTranscriptUpdateBroadcastHandler", () => {
sessionRow.thinkingLevel = "ultra";
});
it("never silently drops an authoritative session message for a slow subscriber", async () => {
const { broadcastToConnIds, handler } = createHandler(false);
handler({
sessionFile: "/tmp/sess-main.jsonl",
sessionKey: "agent:main:main",
message: { role: "user", content: [{ type: "text", text: "shared durable prompt" }] },
messageId: "durable-user-1",
messageSeq: 1,
});
await vi.waitFor(() => expect(broadcastToConnIds).toHaveBeenCalledTimes(1));
expect(broadcastToConnIds).toHaveBeenCalledWith(
"session.message",
expect.objectContaining({
sessionKey: "agent:main:main",
messageId: "durable-user-1",
messageSeq: 1,
}),
expect.any(Set),
);
});
it("keeps transcript snapshots active while plugin finalization delays the terminal event", async () => {
// before_agent_finalize hooks run after the assistant transcript write but
// before terminal delivery. The active-run registry remains authoritative

View File

@@ -311,7 +311,6 @@ async function handleTranscriptUpdateBroadcast(
...sessionSnapshot,
},
connIds,
{ dropIfSlow: true },
);
return;
}

View File

@@ -795,7 +795,12 @@ describe("session.message websocket events", () => {
expect(subscription.ok).toBe(true);
}
for (const [index, text] of ["Sent from the web.", "Sent from the TUI."].entries()) {
const sharedMessages = [
"Sent from the web.",
"Sent from the TUI.",
...Array.from({ length: 30 }, (_, index) => `Shared burst message ${index + 1}.`),
];
for (const [index, text] of sharedMessages.entries()) {
const messageId = `shared-turn-${index + 1}`;
const deliveries = [webWs, tuiWs].map((ws) =>
onceMessage(
@@ -846,10 +851,12 @@ describe("session.message websocket events", () => {
await connectOk(reconnectedTuiWs, { scopes: ["operator.read"] });
const history = await rpcReq(reconnectedTuiWs, "chat.history", { sessionKey });
expect(history.ok).toBe(true);
expect((history.payload as { messages?: unknown[] }).messages).toMatchObject([
{ content: [{ type: "text", text: "Sent from the web." }], role: "user" },
{ content: [{ type: "text", text: "Sent from the TUI." }], role: "user" },
]);
expect((history.payload as { messages?: unknown[] }).messages).toMatchObject(
sharedMessages.map((text) => ({
content: [{ type: "text", text }],
role: "user",
})),
);
} finally {
webWs.close();
tuiWs.close();

View File

@@ -355,6 +355,234 @@ describe("ChatLog", () => {
expect(chatLog.countPendingUsers()).toBe(1);
});
it("preserves live users when same-session history is rebuilt from a stale snapshot", () => {
const chatLog = new ChatLog(40);
chatLog.addLiveUser("Sent from the other client.", {
messageId: "shared-user",
runId: "shared-run",
});
chatLog.clearAll({ preserveLiveUsers: true });
chatLog.addUser("Already persisted in history.", { messageId: "history-user" });
chatLog.restoreLiveUsers();
const rendered = normalizeTestText(chatLog.render(120).join("\n"));
expect(rendered).toContain("Sent from the other client.");
expect(rendered.indexOf("Already persisted in history.")).toBeLessThan(
rendered.indexOf("Sent from the other client."),
);
expect(chatLog.children).toHaveLength(2);
});
it("does not resurrect historical users omitted by the authoritative history snapshot", () => {
const chatLog = new ChatLog(40);
chatLog.addUser("Deleted historical prompt.", {
messageId: "deleted-history-user",
messageSeq: 1,
});
chatLog.addLiveUser("New authoritative live prompt.", {
messageId: "live-user",
messageSeq: 3,
});
chatLog.clearAll({ preserveLiveUsers: true });
chatLog.addUser("Current authoritative history.", {
messageId: "current-history-user",
messageSeq: 2,
});
chatLog.restoreLiveUsers(4);
chatLog.finalizeAssistant("Current authoritative reply.");
chatLog.restoreLiveUsers();
const rendered = normalizeTestText(chatLog.render(120).join("\n"));
expect(rendered).not.toContain("Deleted historical prompt.");
expect(rendered.match(/New authoritative live prompt\./g)).toHaveLength(1);
expect(rendered.indexOf("New authoritative live prompt.")).toBeLessThan(
rendered.indexOf("Current authoritative reply."),
);
});
it("stops restoring live users after authoritative history adopts their identity", () => {
const chatLog = new ChatLog(40);
chatLog.addLiveUser("Adopted live prompt.", {
messageId: "adopted-user",
messageSeq: 1,
});
chatLog.clearAll({ preserveLiveUsers: true });
chatLog.addUser("Adopted live prompt.", {
messageId: "adopted-user",
messageSeq: 1,
});
chatLog.restoreLiveUsers();
chatLog.clearAll({ preserveLiveUsers: true });
chatLog.addUser("Replacement branch prompt.", {
messageId: "replacement-user",
messageSeq: 2,
});
chatLog.restoreLiveUsers();
const rendered = normalizeTestText(chatLog.render(120).join("\n"));
expect(rendered).toContain("Replacement branch prompt.");
expect(rendered).not.toContain("Adopted live prompt.");
});
it("restores a missing canonical user before its higher-sequence persisted reply", () => {
const chatLog = new ChatLog(40);
chatLog.addLiveUser("Authoritative shared prompt.", {
messageId: "shared-user",
messageSeq: 1,
runId: "shared-run",
});
chatLog.clearAll({ preserveLiveUsers: true });
chatLog.addSystem("session agent:main:main");
chatLog.restoreLiveUsers(2);
chatLog.finalizeAssistant("Already persisted reply.");
chatLog.restoreLiveUsers();
const rendered = normalizeTestText(chatLog.render(120).join("\n"));
expect(rendered.match(/Authoritative shared prompt\./g)).toHaveLength(1);
expect(rendered.indexOf("Authoritative shared prompt.")).toBeLessThan(
rendered.indexOf("Already persisted reply."),
);
});
it("restores live canonical users only before higher-sequence history rows", () => {
const chatLog = new ChatLog(40);
chatLog.addLiveUser("First missing prompt.", {
messageId: "shared-user-2",
messageSeq: 2,
});
chatLog.addLiveUser("Second missing prompt.", {
messageId: "shared-user-4",
messageSeq: 4,
});
chatLog.clearAll({ preserveLiveUsers: true });
chatLog.addUser("First persisted prompt.", {
messageId: "history-user-1",
messageSeq: 1,
});
chatLog.restoreLiveUsers(3);
chatLog.addUser("Third persisted prompt.", {
messageId: "history-user-3",
messageSeq: 3,
});
chatLog.restoreLiveUsers(5);
chatLog.finalizeAssistant("Fifth persisted reply.");
chatLog.restoreLiveUsers();
const rendered = normalizeTestText(chatLog.render(120).join("\n"));
const messages = [
"First persisted prompt.",
"First missing prompt.",
"Third persisted prompt.",
"Second missing prompt.",
"Fifth persisted reply.",
];
let previousMessage: string | undefined;
for (const message of messages) {
if (previousMessage !== undefined) {
expect(rendered.indexOf(previousMessage)).toBeLessThan(rendered.indexOf(message));
}
previousMessage = message;
}
});
it("does not restore a live user already included in rebuilt authoritative history", () => {
const chatLog = new ChatLog(40);
chatLog.addLiveUser("Original live prompt.", {
messageId: "shared-user",
runId: "shared-run",
});
chatLog.clearAll({ preserveLiveUsers: true });
chatLog.addUser("Authoritative persisted prompt.", { messageId: "shared-user" });
chatLog.restoreLiveUsers();
let rendered = normalizeTestText(chatLog.render(120).join("\n"));
expect(rendered).toContain("Authoritative persisted prompt.");
expect(rendered).not.toContain("Original live prompt.");
expect(chatLog.children).toHaveLength(1);
chatLog.addLiveUser("Updated persisted prompt.", {
messageId: "shared-user",
runId: "shared-run",
});
rendered = normalizeTestText(chatLog.render(120).join("\n"));
expect(rendered).toContain("Updated persisted prompt.");
expect(rendered).not.toContain("Authoritative persisted prompt.");
expect(chatLog.children).toHaveLength(1);
});
it("restores multiple live users in canonical event order", () => {
const chatLog = new ChatLog(40);
chatLog.addLiveUser("First shared prompt.", {
messageId: "shared-user-1",
runId: "shared-run-1",
});
chatLog.addLiveUser("Second shared prompt.", {
messageId: "shared-user-2",
runId: "shared-run-2",
});
chatLog.clearAll({ preserveLiveUsers: true });
chatLog.addUser("Already persisted in history.", { messageId: "history-user" });
chatLog.restoreLiveUsers();
const rendered = normalizeTestText(chatLog.render(120).join("\n"));
expect(rendered.indexOf("Already persisted in history.")).toBeLessThan(
rendered.indexOf("First shared prompt."),
);
expect(rendered.indexOf("First shared prompt.")).toBeLessThan(
rendered.indexOf("Second shared prompt."),
);
expect(chatLog.children).toHaveLength(3);
});
it("restores both live and pending users across a same-session history rebuild", () => {
const chatLog = new ChatLog(40);
chatLog.addLiveUser("Sent from the other client.", {
messageId: "shared-user",
runId: "shared-run",
});
chatLog.addPendingUser("local-run", "My pending prompt.");
chatLog.clearAll({ preserveLiveUsers: true, preservePendingUsers: true });
chatLog.addUser("Already persisted in history.", { messageId: "history-user" });
chatLog.restoreLiveUsers();
chatLog.restorePendingUsers();
const rendered = normalizeTestText(chatLog.render(120).join("\n"));
expect(rendered).toContain("Sent from the other client.");
expect(rendered).toContain("My pending prompt.");
expect(chatLog.countPendingUsers()).toBe(1);
expect(chatLog.children).toHaveLength(3);
});
it.each([
{ clear: "session switch", options: undefined },
{ clear: "pending-only rebuild", options: { preservePendingUsers: true } },
])("does not leak live users after a $clear", ({ options }) => {
const chatLog = new ChatLog(40);
chatLog.addLiveUser("A previous session's prompt.", {
messageId: "previous-session-user",
runId: "previous-session-run",
});
chatLog.clearAll(options);
chatLog.addUser("Current session history.", { messageId: "current-session-user" });
chatLog.restoreLiveUsers();
const rendered = normalizeTestText(chatLog.render(120).join("\n"));
expect(rendered).toContain("Current session history.");
expect(rendered).not.toContain("A previous session's prompt.");
expect(chatLog.children).toHaveLength(1);
});
it("does not append the same pending component twice when it is already mounted", () => {
const chatLog = new ChatLog(40);
@@ -419,6 +647,119 @@ describe("ChatLog", () => {
);
});
it("preserves a delayed shared prompt and its streaming reply at the scrollback limit", () => {
const chatLog = new ChatLog(20);
chatLog.startAssistant("Already streaming.", "shared-run");
for (let index = 0; index < 19; index += 1) {
chatLog.addSystem(`notice-${index}`);
}
chatLog.addLiveUser("Sent from the other client.", {
messageId: "shared-user",
runId: "shared-run",
});
let rendered = normalizeTestText(chatLog.render(120).join("\n"));
expect(chatLog.children).toHaveLength(20);
expect(rendered).toContain("Sent from the other client.");
expect(rendered.indexOf("Sent from the other client.")).toBeLessThan(
rendered.indexOf("Already streaming."),
);
chatLog.addLiveUser("Sent from the other client.", {
messageId: "shared-user",
runId: "shared-run",
});
chatLog.updateAssistant("Still streaming.", "shared-run");
rendered = normalizeTestText(chatLog.render(120).join("\n"));
expect(chatLog.children).toHaveLength(20);
expect(rendered.match(/Sent from the other client\./g)).toHaveLength(1);
expect(rendered.indexOf("Sent from the other client.")).toBeLessThan(
rendered.indexOf("Still streaming."),
);
});
it("evicts an unrelated older tool instead of a newer transcript row at full scrollback", () => {
const chatLog = new ChatLog(20);
chatLog.startTool("unrelated-old-tool", "read_file", { path: "unrelated-old.txt" });
chatLog.startAssistant("Current streaming reply.", "shared-run");
for (let index = 0; index < 18; index += 1) {
chatLog.addSystem(`newer-notice-${index}`);
}
chatLog.addLiveUser("Current authoritative prompt.", {
messageId: "shared-user",
runId: "shared-run",
});
const rendered = normalizeTestText(chatLog.render(120).join("\n"));
expect(chatLog.children).toHaveLength(20);
expect(rendered).not.toContain("Read File");
expect(rendered).toContain("newer-notice-0");
expect(rendered).toContain("Current streaming reply.");
expect(rendered.indexOf("Current authoritative prompt.")).toBeLessThan(
rendered.indexOf("Current streaming reply."),
);
});
it("preserves a delayed shared prompt, frozen reply, and tool at the scrollback limit", () => {
const chatLog = new ChatLog(20);
chatLog.startAssistant("Before the tool.", "shared-run");
chatLog.startTool("shared-tool", "read_file", { path: "shared.txt" });
for (let index = 0; index < 18; index += 1) {
chatLog.addSystem(`notice-${index}`);
}
chatLog.addLiveUser("Sent from the other client.", {
messageId: "shared-user",
runId: "shared-run",
});
const rendered = normalizeTestText(chatLog.render(120).join("\n"));
expect(chatLog.children).toHaveLength(20);
expect(rendered).toContain("Sent from the other client.");
expect(rendered).toContain("Before the tool.");
expect(rendered).toContain("Read File");
expect(rendered.indexOf("Sent from the other client.")).toBeLessThan(
rendered.indexOf("Before the tool."),
);
expect(rendered.indexOf("Before the tool.")).toBeLessThan(rendered.indexOf("Read File"));
});
it("keeps scrollback bounded when every visible tool belongs to the delayed prompt's run", () => {
const chatLog = new ChatLog(20);
chatLog.startAssistant("Reply with many tools.", "shared-run");
for (let index = 0; index < 19; index += 1) {
chatLog.startTool(
`shared-tool-${index}`,
"read_file",
{ path: `shared-${index}.txt` },
"shared-run",
);
}
chatLog.addLiveUser("Authoritative prompt before many tools.", {
messageId: "shared-user",
runId: "shared-run",
});
const rendered = normalizeTestText(chatLog.render(120).join("\n"));
expect(chatLog.children).toHaveLength(20);
expect(rendered.match(/Authoritative prompt before many tools\./g)).toHaveLength(1);
expect(rendered.indexOf("Authoritative prompt before many tools.")).toBeLessThan(
rendered.indexOf("Reply with many tools."),
);
expect(rendered).not.toContain("shared-0.txt");
expect(rendered).toContain("shared-18.txt");
chatLog.updateToolResult("shared-tool-0", {
content: [{ type: "text", text: "evicted tool must stay detached" }],
});
expect(chatLog.children).toHaveLength(20);
expect(chatLog.render(120).join("\n")).not.toContain("evicted tool must stay detached");
});
it("deduplicates authoritative user events and adopts the matching pending prompt", () => {
const chatLog = new ChatLog(40);
chatLog.addPendingUser("shared-run", "Persisted prompt.");

View File

@@ -21,11 +21,14 @@ type RepeatableSystemMessage = {
export class ChatLog extends Container {
private readonly maxComponents: number;
private toolById = new Map<string, ToolExecutionComponent>();
private toolRunIds = new Map<string, string>();
private streamingRuns = new Map<string, AssistantMessageComponent>();
private frozenAssistants = new Map<string, Set<AssistantMessageComponent>>();
private committedAssistantText = new Map<string, string>();
private latestAssistantText = new Map<string, string>();
private liveUsers = new Map<string, UserMessageComponent>();
private liveUserSequences = new Map<string, number>();
private liveEventUserIds = new Set<string>();
private pendingUsers = new Map<
string,
{
@@ -49,6 +52,7 @@ export class ChatLog extends Container {
for (const [toolId, tool] of this.toolById.entries()) {
if (tool === component) {
this.toolById.delete(toolId);
this.toolRunIds.delete(toolId);
}
}
for (const [runId, message] of this.streamingRuns.entries()) {
@@ -72,6 +76,8 @@ export class ChatLog extends Container {
for (const [messageId, user] of this.liveUsers.entries()) {
if (user === component) {
this.liveUsers.delete(messageId);
this.liveUserSequences.delete(messageId);
this.liveEventUserIds.delete(messageId);
}
}
for (const [runId, entry] of this.pendingSystemNotices.entries()) {
@@ -89,8 +95,11 @@ export class ChatLog extends Container {
private pruneOverflow(protectedComponents?: ReadonlySet<Component>) {
while (this.children.length > this.maxComponents) {
// Protect only the inserted prompt, its reply, and tools owned by that run.
// If owned tools fill the log, evict the oldest tool, never the prompt or reply.
const oldest = protectedComponents
? this.children.find((component) => !protectedComponents.has(component))
? (this.children.find((component) => !protectedComponents.has(component)) ??
this.children.find((component) => component instanceof ToolExecutionComponent))
: this.children[0];
if (!oldest) {
return;
@@ -110,14 +119,28 @@ export class ChatLog extends Container {
this.append(component);
}
clearAll(opts?: { preservePendingUsers?: boolean }) {
clearAll(opts?: { preservePendingUsers?: boolean; preserveLiveUsers?: boolean }) {
this.clear();
this.toolById.clear();
this.toolRunIds.clear();
this.streamingRuns.clear();
this.frozenAssistants.clear();
this.committedAssistantText.clear();
this.latestAssistantText.clear();
this.liveUsers.clear();
if (opts?.preserveLiveUsers) {
// History rows are authoritative snapshots, not in-flight live events.
// Keeping them would resurrect deleted or switched-away transcript branches.
for (const messageId of this.liveUsers.keys()) {
if (!this.liveEventUserIds.has(messageId)) {
this.liveUsers.delete(messageId);
this.liveUserSequences.delete(messageId);
}
}
} else {
this.liveUsers.clear();
this.liveUserSequences.clear();
this.liveEventUserIds.clear();
}
this.pendingSystemNotices.clear();
this.btwMessage = null;
this.repeatableSystemMessage = null;
@@ -131,6 +154,29 @@ export class ChatLog extends Container {
this.removeChild(tool);
}
this.toolById.clear();
this.toolRunIds.clear();
}
restoreLiveUsers(beforeMessageSeq?: number) {
// Rebuilt history replaces matching IDs in addUser; only live prompts
// missing from a stale snapshot are restored before the next canonical row.
for (const messageId of this.liveEventUserIds) {
const component = this.liveUsers.get(messageId);
if (!component) {
this.liveEventUserIds.delete(messageId);
continue;
}
if (this.children.includes(component)) {
continue;
}
if (beforeMessageSeq !== undefined) {
const messageSeq = this.liveUserSequences.get(messageId);
if (messageSeq === undefined || messageSeq >= beforeMessageSeq) {
continue;
}
}
this.appendNonSystem(component);
}
}
restorePendingUsers() {
@@ -203,15 +249,25 @@ export class ChatLog extends Container {
return true;
}
addUser(text: string, options?: { messageId?: string }) {
addUser(text: string, options?: { messageId?: string; messageSeq?: number }) {
const component = new UserMessageComponent(text);
if (options?.messageId) {
this.liveUsers.set(options.messageId, component);
// Once authoritative history contains this identity it is no longer a
// missing live event and must not survive a later deletion or branch.
this.liveEventUserIds.delete(options.messageId);
if (options.messageSeq !== undefined) {
this.liveUserSequences.set(options.messageId, options.messageSeq);
}
}
this.appendNonSystem(component);
}
addLiveUser(text: string, options: { messageId: string; runId?: string }) {
addLiveUser(text: string, options: { messageId: string; messageSeq?: number; runId?: string }) {
this.liveEventUserIds.add(options.messageId);
if (options.messageSeq !== undefined) {
this.liveUserSequences.set(options.messageId, options.messageSeq);
}
const existing = this.liveUsers.get(options.messageId);
if (existing) {
existing.setText(text);
@@ -239,7 +295,15 @@ export class ChatLog extends Container {
// older components so the newly recovered prompt cannot disappear.
this.repeatableSystemMessage = null;
this.children.splice(assistantIndex, 0, component);
this.pruneOverflow(new Set([component, assistant]));
const protectedComponents = new Set<Component>([component, assistant]);
if (options.runId) {
for (const [toolId, tool] of this.toolById) {
if (this.toolRunIds.get(toolId) === options.runId) {
protectedComponents.add(tool);
}
}
}
this.pruneOverflow(protectedComponents);
return component;
}
this.appendNonSystem(component);
@@ -477,16 +541,21 @@ export class ChatLog extends Container {
return this.btwMessage !== null;
}
startTool(toolCallId: string, toolName: string, args: unknown) {
startTool(toolCallId: string, toolName: string, args: unknown, runId?: string) {
const existing = this.toolById.get(toolCallId);
if (existing) {
existing.setArgs(args);
return existing;
}
const owningRunId =
runId ?? (this.streamingRuns.size === 1 ? this.streamingRuns.keys().next().value : undefined);
this.freezeStreamingAssistants();
const component = new ToolExecutionComponent(toolName, args);
component.setExpanded(this.toolsExpanded);
this.toolById.set(toolCallId, component);
if (owningRunId) {
this.toolRunIds.set(toolCallId, owningRunId);
}
this.appendNonSystem(component);
return component;
}

View File

@@ -239,7 +239,12 @@ describe("tui-event-handlers: handleAgentEvent", () => {
handleAgentEvent(evt);
expect(chatLog.startTool).toHaveBeenCalledWith("tc1", "exec", { command: "echo hi" });
expect(chatLog.startTool).toHaveBeenCalledWith(
"tc1",
"exec",
{ command: "echo hi" },
"run-123",
);
expect(tui.requestRender).toHaveBeenCalledTimes(1);
});
@@ -968,7 +973,7 @@ describe("tui-event-handlers: handleAgentEvent", () => {
handleAgentEvent(agentEvt);
expect(chatLog.startTool).toHaveBeenCalledWith("tc1", "exec", undefined);
expect(chatLog.startTool).toHaveBeenCalledWith("tc1", "exec", undefined, "run-42");
});
it("accepts chat events when session key is an alias of the active canonical key", () => {
@@ -1647,7 +1652,12 @@ describe("tui-event-handlers: handleAgentEvent", () => {
data: { phase: "start", toolCallId: "tc-final", name: "session_status" },
});
expect(chatLog.startTool).toHaveBeenCalledWith("tc-final", "session_status", undefined);
expect(chatLog.startTool).toHaveBeenCalledWith(
"tc-final",
"session_status",
undefined,
"run-final",
);
expect(tui.requestRender).toHaveBeenCalled();
});
@@ -2506,6 +2516,7 @@ describe("tui-event-handlers: handleAgentEvent", () => {
expect(chatLog.addLiveUser).toHaveBeenCalledWith("Sent from the other client.", {
messageId: "shared-session-user",
messageSeq: 1,
runId,
});
expect(state.activeChatRunId).toBe(runId);

View File

@@ -7,7 +7,11 @@ import {
sanitizeRenderableText,
} from "./tui-formatters.js";
import { createTuiRunLifecycle } from "./tui-run-lifecycle.js";
import { matchesSelectedTuiSession, readTuiSessionUserMessage } from "./tui-session-events.js";
import {
matchesSelectedTuiSession,
readTuiSessionUserMessage,
readTuiTranscriptMessageSequence,
} from "./tui-session-events.js";
import { TuiSessionRunCoordinator } from "./tui-session-run-coordinator.js";
import {
clearPendingSubmit,
@@ -25,8 +29,11 @@ import type {
} from "./tui-types.js";
type EventHandlerChatLog = {
addLiveUser: (text: string, options: { messageId: string; runId?: string }) => void;
startTool: (toolCallId: string, toolName: string, args: unknown) => void;
addLiveUser: (
text: string,
options: { messageId: string; messageSeq?: number; runId?: string },
) => void;
startTool: (toolCallId: string, toolName: string, args: unknown, runId?: string) => void;
updateToolResult: (
toolCallId: string,
result: unknown,
@@ -491,8 +498,16 @@ export function createEventHandlers(context: EventHandlerContext) {
const liveUserMessage = readTuiSessionUserMessage(evt);
if (liveUserMessage) {
const envelopeSequence = evt.messageSeq;
const messageSeq =
typeof envelopeSequence === "number" &&
Number.isSafeInteger(envelopeSequence) &&
envelopeSequence > 0
? envelopeSequence
: readTuiTranscriptMessageSequence(evt.message);
chatLog.addLiveUser(liveUserMessage.text, {
messageId: liveUserMessage.messageId,
...(messageSeq !== undefined ? { messageSeq } : {}),
...(liveUserMessage.runId ? { runId: liveUserMessage.runId } : {}),
});
tui.requestRender();
@@ -588,7 +603,7 @@ export function createEventHandlers(context: EventHandlerContext) {
return;
}
if (phase === "start") {
chatLog.startTool(toolCallId, toolName, data.args);
chatLog.startTool(toolCallId, toolName, data.args, evt.runId);
} else if (phase === "update") {
if (!allowToolOutput) {
return;

View File

@@ -60,6 +60,142 @@ describe("formatGoalFooter", () => {
});
describe("extractTextFromMessage", () => {
it.each([
{
name: "a browser image block",
content: [
{
type: "image",
url: "/persisted-image.png",
source: { type: "url", url: "/persisted-image.png" },
},
],
expected: "Attached image",
},
{
name: "a persisted image block",
content: [{ type: "image", source: { type: "url", url: "/persisted-image.png" } }],
expected: "Attached image",
},
{
name: "a browser document block",
content: [
{
type: "attachment",
attachment: {
url: "/report.pdf",
kind: "document",
label: "report.pdf",
mimeType: "application/pdf",
},
},
],
expected: "Attached file: report.pdf",
},
{
name: "a browser audio block",
content: [
{
type: "attachment",
attachment: {
url: "/voice.ogg",
kind: "audio",
label: "voice.ogg",
mimeType: "audio/ogg",
},
},
],
expected: "Attached file: voice.ogg",
},
{
name: "a browser file with the default label",
content: [
{
type: "attachment",
attachment: { url: "/document", kind: "document", label: "Attached file" },
},
],
expected: "Attached file",
},
{
name: "multiple ordered browser attachments",
content: [
{ type: "image", source: { type: "url", url: "/image.png" } },
{
type: "attachment",
attachment: { url: "/report.pdf", kind: "document", label: "report.pdf" },
},
],
expected: "Attached image\nAttached file: report.pdf",
},
])("renders an attachment-only user turn containing $name", ({ content, expected }) => {
expect(extractTextFromMessage({ role: "user", content })).toBe(expected);
});
it.each([
{
name: "image",
media: [{ path: "/media/inbound/generated-image.png", contentType: "image/png" }],
expected: "Attached image",
},
{
name: "image inferred from its canonical path",
media: [{ path: "/media/inbound/media-only.png" }],
expected: "Attached image",
},
{
name: "file",
media: [{ path: "/media/inbound/generated-report.pdf", contentType: "application/pdf" }],
expected: "Attached file",
},
{
name: "ordered image and file",
media: [
{ path: "/media/inbound/generated-image.png", contentType: "image/png" },
{ path: "/media/inbound/generated-report.pdf", contentType: "application/pdf" },
],
expected: "Attached image\nAttached file",
},
])("renders an empty durable user turn with canonical $name media", ({ media, expected }) => {
expect(
extractTextFromMessage({
role: "user",
content: "",
__openclaw: { media },
}),
).toBe(expected);
});
it("keeps an ordinary user prompt unchanged when it also has attachments", () => {
expect(
extractTextFromMessage({
role: "user",
content: [
{ type: "text", text: "Describe this image" },
{ type: "image", source: { type: "url", url: "/image.png" } },
],
}),
).toBe("Describe this image");
});
it("sanitizes the display name of an attachment-only user turn", () => {
expect(
extractTextFromMessage({
role: "user",
content: [
{
type: "attachment",
attachment: {
url: "/report.pdf",
kind: "document",
label: "\u001b[31mreport.pdf\u001b[0m\u0000",
},
},
],
}),
).toBe("Attached file: report.pdf");
});
it("prefers final_answer text over commentary text for assistant messages", () => {
const text = extractTextFromMessage({
role: "assistant",

View File

@@ -4,6 +4,7 @@ import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js";
import { stripLeadingInboundMetadata } from "../auto-reply/reply/strip-inbound-meta.js";
import type { SessionGoal } from "../config/sessions/types.js";
import { formatErrorMessage } from "../infra/errors.js";
import { isImageMediaFact, readPersistedMediaFacts } from "../media/media-facts.js";
import { formatRawAssistantErrorForUi } from "../shared/assistant-error-format.js";
import { extractAssistantVisibleText } from "../shared/chat-message-content.js";
import { chunkTextByBreakResolver } from "../shared/text-chunking.js";
@@ -431,6 +432,37 @@ function extractTextBlocks(content: unknown, opts?: { includeThinking?: boolean
});
}
function extractUserAttachmentText(record: Record<string, unknown>): string {
const attachments: string[] = [];
if (Array.isArray(record.content)) {
for (const block of record.content) {
const entry = asMessageRecord(block);
if (entry?.type === "image") {
attachments.push("Attached image");
} else if (entry?.type === "attachment") {
const attachment = asMessageRecord(entry.attachment);
const label =
typeof attachment?.label === "string"
? sanitizeRenderableText(attachment.label).trim()
: "";
attachments.push(
label && label !== "Attached file" ? `Attached file: ${label}` : "Attached file",
);
}
}
}
if (attachments.length > 0) {
return attachments.join("\n");
}
// Gateway-persisted attachment-only turns keep blank content and carry
// their authoritative attachments in __openclaw.media instead.
return (readPersistedMediaFacts(record) ?? [])
.filter((fact) => fact.path || fact.url || fact.contentType || fact.kind)
.map((fact) => (isImageMediaFact(fact) ? "Attached image" : "Attached file"))
.join("\n");
}
export function extractTextFromMessage(
message: unknown,
opts?: { includeThinking?: boolean },
@@ -454,6 +486,10 @@ export function extractTextFromMessage(
return text;
}
if (record.role === "user") {
return extractUserAttachmentText(record);
}
const errorText = formatAssistantErrorFromRecord(record);
if (!errorText) {
return "";

View File

@@ -190,8 +190,8 @@ describe.sequential("TUI PTY harness", () => {
STARTUP_TEST_TIMEOUT_MS,
);
it.each([{ failures: 1 }, { failures: 2 }])(
"bounds session subscription recovery after $failures startup failures",
it.each([{ failures: 1 }, { failures: 2 }, { failures: 3 }, { failures: 4 }])(
"recovers session subscription after $failures startup failures",
async ({ failures }) => {
const subscriptionFixture = await startTuiFixture({
env: { OPENCLAW_TUI_PTY_SUBSCRIBE_FAILURES: String(failures) },
@@ -200,7 +200,7 @@ describe.sequential("TUI PTY harness", () => {
await subscriptionFixture.run.waitForOutput("local ready | idle", STARTUP_TIMEOUT_MS);
const entries = await readFixtureLog(subscriptionFixture.logPath);
expect(entries.filter((entry) => entry.method === "subscribeSessionEvents")).toHaveLength(
2,
failures + 1,
);
expect(entries.filter((entry) => entry.method === "subscribeSessionFailure")).toHaveLength(
failures,
@@ -218,6 +218,33 @@ describe.sequential("TUI PTY harness", () => {
STARTUP_TEST_TIMEOUT_MS,
);
it(
"never reports ready after exhausting session subscription recovery",
async () => {
const subscriptionFixture = await startTuiFixture({
env: { OPENCLAW_TUI_PTY_SUBSCRIBE_FAILURES: "5" },
});
try {
await subscriptionFixture.run.waitForOutput(
"session event subscribe failed",
STARTUP_TIMEOUT_MS,
);
const entries = await readFixtureLog(subscriptionFixture.logPath);
expect(entries.filter((entry) => entry.method === "subscribeSessionEvents")).toHaveLength(
5,
);
expect(entries.filter((entry) => entry.method === "subscribeSessionFailure")).toHaveLength(
5,
);
expect(entries.some((entry) => entry.method === "loadHistory")).toBe(false);
expect(subscriptionFixture.run.visibleOutput()).not.toContain("local ready | idle");
} finally {
await subscriptionFixture.cleanup();
}
},
STARTUP_TEST_TIMEOUT_MS,
);
it("refreshes pending approvals before loading history", async () => {
await fixture.waitForLogEntry((entry) => entry.method === "listPluginApprovals");
await fixture.waitForLogEntry((entry) => entry.method === "listTaskSuggestions");

View File

@@ -1,6 +1,6 @@
// Covers TUI session action routing and backend calls.
import { describe, expect, it, vi } from "vitest";
import type { ChatLog } from "./components/chat-log.js";
import { ChatLog } from "./components/chat-log.js";
import type { TuiBackend } from "./tui-backend.js";
import { createSessionActions } from "./tui-session-actions.js";
import { TUI_SESSION_LOOKUP_LIMIT } from "./tui-session-list-policy.js";
@@ -44,6 +44,7 @@ describe("tui session actions", () => {
addUser,
finalizeAssistant: vi.fn(),
reconcilePendingUsers: vi.fn().mockReturnValue([]),
restoreLiveUsers: vi.fn(),
restorePendingUsers: vi.fn(),
updateAssistant: vi.fn(),
startTool: vi.fn(),
@@ -87,6 +88,7 @@ describe("tui session actions", () => {
clearPendingUsers: vi.fn(),
clearAll: vi.fn(),
reconcilePendingUsers: vi.fn().mockReturnValue([]),
restoreLiveUsers: vi.fn(),
restorePendingUsers: vi.fn(),
} as unknown as import("./components/chat-log.js").ChatLog,
btw: createBtwPresenter(),
@@ -538,6 +540,259 @@ describe("tui session actions", () => {
});
});
it("preserves an authoritative user received while same-session history is loading", async () => {
const deferredHistory = createDeferred<unknown>();
const chatLog = new ChatLog();
const state = createBaseState({ currentSessionId: "session-main" });
const { loadHistory } = createTestSessionActions({
client: {
listSessions: vi.fn(),
loadHistory: vi.fn(() => deferredHistory.promise),
} as unknown as TuiBackend,
chatLog,
state,
});
const loading = loadHistory();
chatLog.addLiveUser("Browser prompt received during refresh", {
messageId: "shared-user-1",
messageSeq: 1,
runId: "shared-run-1",
});
deferredHistory.resolve({
sessionId: "session-main",
sessionInfo: { key: "agent:main:main", sessionId: "session-main" },
messages: [
{
role: "assistant",
content: "History completed",
__openclaw: { id: "shared-assistant-1", seq: 2 },
},
],
});
await expect(loading).resolves.toMatchObject({ loaded: true });
const rendered = chatLog.render(120).join("\n");
expect(rendered).toContain("Browser prompt received during refresh");
expect(rendered.match(/Browser prompt received during refresh/g)).toHaveLength(1);
expect(rendered).toContain("History completed");
expect(rendered.indexOf("Browser prompt received during refresh")).toBeLessThan(
rendered.indexOf("History completed"),
);
});
it("does not restore deleted history while recovering an in-flight authoritative live user", async () => {
const deferredHistory = createDeferred<unknown>();
const chatLog = new ChatLog();
chatLog.addUser("Deleted prompt from an earlier branch", {
messageId: "deleted-history-user",
messageSeq: 1,
});
const state = createBaseState({ currentSessionId: "session-main" });
const { loadHistory } = createTestSessionActions({
client: {
listSessions: vi.fn(),
loadHistory: vi.fn(() => deferredHistory.promise),
} as unknown as TuiBackend,
chatLog,
state,
});
const loading = loadHistory();
chatLog.addLiveUser("Browser prompt received during refresh", {
messageId: "live-user",
messageSeq: 3,
runId: "live-run",
});
deferredHistory.resolve({
sessionId: "session-main",
sessionInfo: { key: "agent:main:main", sessionId: "session-main" },
messages: [
{
role: "user",
content: "Current branch prompt",
__openclaw: { id: "current-history-user", seq: 2 },
},
{
role: "assistant",
content: "Current branch reply",
__openclaw: { id: "current-assistant", seq: 4 },
},
],
});
await expect(loading).resolves.toMatchObject({ loaded: true });
const rendered = chatLog.render(120).join("\n");
expect(rendered).not.toContain("Deleted prompt from an earlier branch");
expect(rendered.match(/Browser prompt received during refresh/g)).toHaveLength(1);
expect(rendered.indexOf("Browser prompt received during refresh")).toBeLessThan(
rendered.indexOf("Current branch reply"),
);
});
it("deduplicates an authoritative live user already included in same-session history", async () => {
const deferredHistory = createDeferred<unknown>();
const chatLog = new ChatLog();
const state = createBaseState({ currentSessionId: "session-main" });
const { loadHistory } = createTestSessionActions({
client: {
listSessions: vi.fn(),
loadHistory: vi.fn(() => deferredHistory.promise),
} as unknown as TuiBackend,
chatLog,
state,
});
const loading = loadHistory();
chatLog.addLiveUser("Persisted browser prompt", {
messageId: "shared-user-2",
messageSeq: 1,
runId: "shared-run-2",
});
deferredHistory.resolve({
sessionId: "session-main",
sessionInfo: { key: "agent:main:main", sessionId: "session-main" },
messages: [
{
role: "user",
content: "Persisted browser prompt",
__openclaw: { id: "shared-user-2", seq: 1 },
},
{
role: "assistant",
content: "Persisted reply",
__openclaw: { id: "shared-assistant-2", seq: 2 },
},
],
});
await expect(loading).resolves.toMatchObject({ loaded: true });
const rendered = chatLog.render(120).join("\n");
expect(rendered.match(/Persisted browser prompt/g)).toHaveLength(1);
expect(rendered).toContain("Persisted reply");
});
it("preserves new-session live users without leaking the previous session during a switch", async () => {
const deferredHistory = createDeferred<unknown>();
const chatLog = new ChatLog();
chatLog.addLiveUser("Private prompt from previous session", {
messageId: "previous-user",
runId: "previous-run",
});
const state = createBaseState({ currentSessionId: "session-main" });
const { setSession } = createTestSessionActions({
client: {
listSessions: vi.fn(),
loadHistory: vi.fn(() => deferredHistory.promise),
} as unknown as TuiBackend,
chatLog,
state,
});
const switching = setSession("agent:main:other");
chatLog.addLiveUser("Browser prompt in selected session", {
messageId: "other-user",
messageSeq: 1,
runId: "other-run",
});
deferredHistory.resolve({
sessionId: "session-other",
sessionInfo: { key: "agent:main:other", sessionId: "session-other" },
messages: [
{
role: "assistant",
content: "Other session reply",
__openclaw: { id: "other-assistant", seq: 2 },
},
],
});
await switching;
const rendered = chatLog.render(120).join("\n");
expect(rendered).toContain("Browser prompt in selected session");
expect(rendered.match(/Browser prompt in selected session/g)).toHaveLength(1);
expect(rendered).toContain("Other session reply");
expect(rendered.indexOf("Browser prompt in selected session")).toBeLessThan(
rendered.indexOf("Other session reply"),
);
expect(rendered).not.toContain("Private prompt from previous session");
});
it("keeps a recovered authoritative user ahead of its reply at full scrollback", async () => {
const deferredHistory = createDeferred<unknown>();
const chatLog = new ChatLog(20);
const state = createBaseState({ currentSessionId: "session-main" });
const { loadHistory } = createTestSessionActions({
client: {
listSessions: vi.fn(),
loadHistory: vi.fn(() => deferredHistory.promise),
} as unknown as TuiBackend,
chatLog,
state,
});
const loading = loadHistory();
chatLog.addLiveUser("Browser prompt at the scrollback limit", {
messageId: "scrollback-user",
messageSeq: 19,
runId: "scrollback-run",
});
deferredHistory.resolve({
sessionId: "session-main",
sessionInfo: { key: "agent:main:main", sessionId: "session-main" },
messages: [
...Array.from({ length: 18 }, (_, index) => ({
role: "user",
content: `Earlier history ${index + 1}`,
__openclaw: { id: `history-user-${index + 1}`, seq: index + 1 },
})),
{
role: "assistant",
content: "Reply at the scrollback limit",
__openclaw: { id: "scrollback-assistant", seq: 20 },
},
],
});
await expect(loading).resolves.toMatchObject({ loaded: true });
const rendered = chatLog.render(120).join("\n");
expect(rendered.match(/Browser prompt at the scrollback limit/g)).toHaveLength(1);
expect(rendered.indexOf("Browser prompt at the scrollback limit")).toBeLessThan(
rendered.indexOf("Reply at the scrollback limit"),
);
expect(chatLog.children).toHaveLength(20);
});
it("discards old live users when a same-key history response rotates the session id", async () => {
const deferredHistory = createDeferred<unknown>();
const chatLog = new ChatLog();
const state = createBaseState({ currentSessionId: "session-before-reset" });
const { loadHistory } = createTestSessionActions({
client: {
listSessions: vi.fn(),
loadHistory: vi.fn(() => deferredHistory.promise),
} as unknown as TuiBackend,
chatLog,
state,
});
const loading = loadHistory();
chatLog.addLiveUser("Private prompt from before reset", {
messageId: "before-reset-user",
runId: "before-reset-run",
});
deferredHistory.resolve({
sessionId: "session-after-reset",
sessionInfo: { key: "agent:main:main", sessionId: "session-after-reset" },
messages: [{ role: "assistant", content: "Fresh session reply" }],
});
await expect(loading).resolves.toMatchObject({ loaded: true });
const rendered = chatLog.render(120).join("\n");
expect(rendered).toContain("Fresh session reply");
expect(rendered).not.toContain("Private prompt from before reset");
});
it("accepts older session snapshots after switching session keys", async () => {
const listSessions = vi.fn().mockResolvedValue({
ts: Date.now(),
@@ -693,6 +948,7 @@ describe("tui session actions", () => {
addUser: vi.fn(),
finalizeAssistant: vi.fn(),
reconcilePendingUsers: vi.fn().mockReturnValue([]),
restoreLiveUsers: vi.fn(),
restorePendingUsers: vi.fn(),
updateAssistant,
startTool: vi.fn(),
@@ -728,6 +984,7 @@ describe("tui session actions", () => {
addUser: vi.fn(),
finalizeAssistant: vi.fn(),
reconcilePendingUsers: vi.fn().mockReturnValue([]),
restoreLiveUsers: vi.fn(),
restorePendingUsers: vi.fn(),
updateAssistant,
startTool: vi.fn(),
@@ -760,6 +1017,7 @@ describe("tui session actions", () => {
addUser: vi.fn(),
finalizeAssistant: vi.fn(),
reconcilePendingUsers: vi.fn().mockReturnValue([]),
restoreLiveUsers: vi.fn(),
restorePendingUsers: vi.fn(),
updateAssistant,
startTool: vi.fn(),
@@ -1723,6 +1981,7 @@ describe("tui session actions", () => {
clearAll: vi.fn(),
clearPendingUsers: vi.fn(),
reconcilePendingUsers: vi.fn().mockReturnValue([]),
restoreLiveUsers: vi.fn(),
restorePendingUsers: vi.fn(),
};
@@ -1736,7 +1995,11 @@ describe("tui session actions", () => {
const result = await runLoadHistory();
expect(chatLog.clearAll).toHaveBeenCalledWith({ preservePendingUsers: true });
expect(chatLog.clearAll).toHaveBeenCalledWith({
preservePendingUsers: true,
preserveLiveUsers: true,
});
expect(chatLog.restoreLiveUsers).toHaveBeenCalledTimes(1);
expect(chatLog.reconcilePendingUsers).toHaveBeenCalledWith([
{ text: "persisted", timestamp: 2_000 },
]);
@@ -1755,6 +2018,7 @@ describe("tui session actions", () => {
finalizeAssistant: vi.fn(),
clearAll: vi.fn(),
reconcilePendingUsers: vi.fn().mockReturnValue(["run-pending"]),
restoreLiveUsers: vi.fn(),
restorePendingUsers: vi.fn(),
};
const state = createBaseState({
@@ -1780,6 +2044,7 @@ describe("tui session actions", () => {
finalizeAssistant: vi.fn(),
clearAll: vi.fn(),
reconcilePendingUsers: vi.fn().mockReturnValue([]),
restoreLiveUsers: vi.fn(),
restorePendingUsers: vi.fn(),
};
const state = createBaseState({

View File

@@ -18,7 +18,10 @@ import {
formatTuiErrorMessage,
isCommandMessage,
} from "./tui-formatters.js";
import { readTuiSessionUserMessage } from "./tui-session-events.js";
import {
readTuiSessionUserMessage,
readTuiTranscriptMessageSequence,
} from "./tui-session-events.js";
import { TUI_SESSION_LOOKUP_LIMIT } from "./tui-session-list-policy.js";
import * as submit from "./tui-submit-state.js";
import type { SessionInfo, TuiHistoryLoadResult, TuiOptions, TuiStateAccess } from "./tui-types.js";
@@ -484,6 +487,7 @@ export function createSessionActions(context: SessionActionContext) {
// latest request may render, or a slow reload can replace a newer selection.
const generation = ++historyLoadGeneration;
const selection = captureSessionSelection();
const previousSessionId = state.currentSessionId;
const isCurrentLoad = () =>
generation === historyLoadGeneration && isCurrentSessionSelection(selection);
try {
@@ -544,7 +548,13 @@ export function createSessionActions(context: SessionActionContext) {
}
const showTools = (state.sessionInfo.verboseLevel ?? "off") !== "off";
const historyUsers: Array<{ text: string; timestamp?: number | null }> = [];
chatLog.clearAll({ preservePendingUsers: true });
// An authoritative live prompt can arrive while history is in flight.
// Preserve it only while this response still owns the same session generation.
chatLog.clearAll({
preservePendingUsers: true,
preserveLiveUsers:
previousSessionId === null || previousSessionId === state.currentSessionId,
});
btw.clear();
chatLog.addSystem(`session ${state.currentSessionKey}`);
for (const entry of record.messages ?? []) {
@@ -552,6 +562,10 @@ export function createSessionActions(context: SessionActionContext) {
continue;
}
const message = entry as Record<string, unknown>;
const messageSeq = readTuiTranscriptMessageSequence(message);
if (messageSeq !== undefined) {
chatLog.restoreLiveUsers(messageSeq);
}
if (isCommandMessage(message)) {
const text = extractTextFromMessage(message);
if (text) {
@@ -568,7 +582,10 @@ export function createSessionActions(context: SessionActionContext) {
});
const liveUserMessage = readTuiSessionUserMessage({ message });
if (liveUserMessage) {
chatLog.addUser(text, { messageId: liveUserMessage.messageId });
chatLog.addUser(text, {
messageId: liveUserMessage.messageId,
...(messageSeq !== undefined ? { messageSeq } : {}),
});
} else {
chatLog.addUser(text);
}
@@ -605,6 +622,7 @@ export function createSessionActions(context: SessionActionContext) {
);
}
}
chatLog.restoreLiveUsers();
submit.reconcilePendingSubmitHistory(state, chatLog.reconcilePendingUsers(historyUsers));
chatLog.restorePendingUsers();
// Restore a run still streaming for this session+agent that the gateway
@@ -645,9 +663,11 @@ export function createSessionActions(context: SessionActionContext) {
};
const setSession = async (rawKey: string) => {
const previousSelection = captureSessionSelection();
const nextKey = resolveSessionKey(rawKey);
updateAgentFromSessionKey(nextKey);
state.currentSessionKey = nextKey;
const selectionChanged = !isCurrentSessionSelection(previousSelection);
state.activeChatRunId = null;
submit.clearPendingSubmit(state);
setActivityStatus("idle");
@@ -656,6 +676,10 @@ export function createSessionActions(context: SessionActionContext) {
// so refresh data for the newly selected session isn't rejected as stale.
state.sessionInfo.updatedAt = null;
state.historyLoaded = false;
if (selectionChanged) {
// Live prompt identities belong to the old selection, not its pending successor.
chatLog.clearAll();
}
chatLog.clearPendingUsers();
clearLocalRunIds?.();
btw.clear();

View File

@@ -110,6 +110,53 @@ describe("matchesSelectedTuiSession", () => {
});
describe("readTuiSessionUserMessage", () => {
it.each([
{
name: "image block",
content: [{ type: "image", source: { type: "url", url: "/image.png" } }],
media: undefined,
expected: "Attached image",
},
{
name: "document block",
content: [
{
type: "attachment",
attachment: { url: "/report.pdf", kind: "document", label: "report.pdf" },
},
],
media: undefined,
expected: "Attached file: report.pdf",
},
{
name: "canonical persisted media",
content: "",
media: [{ path: "/media/inbound/image.png", contentType: "image/png" }],
expected: "Attached image",
},
])("accepts an authoritative attachment-only $name event", ({ content, media, expected }) => {
expect(
readTuiSessionUserMessage({
sessionKey: "agent:main:main",
messageId: "attachment-user-1",
message: {
role: "user",
content,
__openclaw: {
id: "attachment-user-1",
idempotencyKey: "attachment-run-1:user",
seq: 1,
...(media ? { media } : {}),
},
},
} satisfies SessionMessageEvent),
).toEqual({
messageId: "attachment-user-1",
runId: "attachment-run-1",
text: expected,
});
});
it("recovers the durable prompt identity and owning chat run", () => {
expect(
readTuiSessionUserMessage({

View File

@@ -9,6 +9,21 @@ type TuiSessionEvent = {
agentId?: string;
};
/** Reads the monotonic transcript position shared by persisted and live messages. */
export function readTuiTranscriptMessageSequence(message: unknown): number | undefined {
if (!message || typeof message !== "object" || Array.isArray(message)) {
return undefined;
}
const marker = (message as Record<string, unknown>)["__openclaw"];
if (!marker || typeof marker !== "object" || Array.isArray(marker)) {
return undefined;
}
const sequence = (marker as Record<string, unknown>).seq;
return typeof sequence === "number" && Number.isSafeInteger(sequence) && sequence > 0
? sequence
: undefined;
}
/** Reads the durable user identity without mistaking another run's prompt for this one. */
export function readTuiSessionUserMessage(event: SessionMessageEvent): {
text: string;

View File

@@ -2,6 +2,7 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import {
CombinedAutocompleteProvider,
@@ -101,6 +102,8 @@ const DIST_ENTRY_MJS_PATH = fileURLToPath(new URL("../../dist/entry.mjs", import
const OPENAI_CODEX_PROVIDER = "openai";
const CODEX_CLI_LOOKUP_TIMEOUT_MS = 5_000;
const SESSION_SUBSCRIPTION_MAX_ATTEMPTS = 5;
const SESSION_SUBSCRIPTION_RETRY_DELAY_MS = 25;
type RunTuiOptions = TuiOptions & {
backend?: TuiBackend;
@@ -1674,7 +1677,7 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
setActivityStatus("starting up");
}
void (async () => {
for (let attempt = 0; attempt < 2; attempt += 1) {
for (let attempt = 0; attempt < SESSION_SUBSCRIPTION_MAX_ATTEMPTS; attempt += 1) {
try {
await client.subscribeSessionEvents?.();
break;
@@ -1682,12 +1685,21 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
if (!ownsConnection()) {
return;
}
// Subscription is idempotent; recover one transient Gateway failure
// without leaving this connected TUI permanently unsubscribed.
if (attempt === 0) {
continue;
if (attempt + 1 === SESSION_SUBSCRIPTION_MAX_ATTEMPTS) {
chatLog.addSystem(`session event subscribe failed: ${formatTuiErrorMessage(err)}`);
if (activityStatus === "starting up") {
setActivityStatus("idle");
}
setConnectionStatus("session event subscription failed");
tui.requestRender();
return;
}
// A connected but unsubscribed TUI misses every peer's message. Wait
// between idempotent retries and abandon this generation on reconnect.
await delay(SESSION_SUBSCRIPTION_RETRY_DELAY_MS * (attempt + 1));
if (!ownsConnection()) {
return;
}
chatLog.addSystem(`session event subscribe failed: ${formatTuiErrorMessage(err)}`);
}
}
if (!ownsConnection()) {

View File

@@ -292,104 +292,110 @@ suite.define(() => {
},
);
it("inserts a delayed persisted prompt ahead of an already-finalized reply", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, { historyMessages: [] });
const runId = "shared-session-finalized-run";
const prompt = "The persisted prompt arrived after the final.";
const finalText = "The reply was already finished.";
const userMessage = {
__openclaw: { id: "finalized-run-user", idempotencyKey: `${runId}:user`, seq: 1 },
content: [{ text: prompt, type: "text" }],
role: "user",
timestamp: Date.now(),
};
const assistantMessage = {
__openclaw: { id: "finalized-run-assistant", seq: 2 },
content: [{ text: finalText, type: "text" }],
role: "assistant",
timestamp: Date.now(),
};
it.each([
{ history: "up-to-date", includesPrompt: true },
{ history: "stale", includesPrompt: false },
])(
"preserves a delayed persisted prompt ahead of a finalized reply with $history history",
async ({ includesPrompt }) => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, { historyMessages: [] });
const runId = "shared-session-finalized-run";
const prompt = "The persisted prompt arrived after the final.";
const finalText = "The reply was already finished.";
const userMessage = {
__openclaw: { id: "finalized-run-user", idempotencyKey: `${runId}:user`, seq: 1 },
content: [{ text: prompt, type: "text" }],
role: "user",
timestamp: Date.now(),
};
const assistantMessage = {
__openclaw: { id: "finalized-run-assistant", seq: 2 },
content: [{ text: finalText, type: "text" }],
role: "assistant",
timestamp: Date.now(),
};
try {
await page.goto(`${suite.server.baseUrl}chat`);
await gateway.waitForRequest("chat.startup");
await gateway.emitGatewayEvent("chat", {
deltaText: finalText,
message: {
content: [{ text: finalText, type: "text" }],
role: "assistant",
timestamp: Date.now(),
},
runId,
seq: 1,
sessionKey: "main",
state: "delta",
});
await page.locator(".chat-bubble.streaming", { hasText: finalText }).waitFor({
timeout: 10_000,
});
await gateway.emitChatFinal({ runId, text: finalText });
await page.locator(".chat-group.assistant .chat-text", { hasText: finalText }).waitFor({
timeout: 10_000,
});
await gateway.setHistoryMessages([userMessage, assistantMessage]);
await gateway.deferNext("chat.history");
await gateway.emitGatewayEvent("session.message", {
activeRunIds: [],
clientRunId: runId,
hasActiveRun: false,
message: userMessage,
messageId: "finalized-run-user",
messageSeq: 1,
session: {
try {
await page.goto(`${suite.server.baseUrl}chat`);
await gateway.waitForRequest("chat.startup");
await gateway.emitGatewayEvent("chat", {
deltaText: finalText,
message: {
content: [{ text: finalText, type: "text" }],
role: "assistant",
timestamp: Date.now(),
},
runId,
seq: 1,
sessionKey: "main",
state: "delta",
});
await page.locator(".chat-bubble.streaming", { hasText: finalText }).waitFor({
timeout: 10_000,
});
await gateway.emitChatFinal({ runId, text: finalText });
await page.locator(".chat-group.assistant .chat-text", { hasText: finalText }).waitFor({
timeout: 10_000,
});
await gateway.setHistoryMessages([userMessage, assistantMessage]);
await gateway.deferNext("chat.history");
await gateway.emitGatewayEvent("session.message", {
activeRunIds: [],
clientRunId: runId,
hasActiveRun: false,
key: "main",
kind: "direct",
status: "done",
updatedAt: Date.now(),
},
sessionKey: "main",
});
message: userMessage,
messageId: "finalized-run-user",
messageSeq: 1,
session: {
activeRunIds: [],
hasActiveRun: false,
key: "main",
kind: "direct",
status: "done",
updatedAt: Date.now(),
},
sessionKey: "main",
});
await expect
.poll(() =>
page.locator(".chat-thread-inner").evaluate(
(thread, texts) => {
const user = Array.from(thread.querySelectorAll(".chat-group.user")).find((row) =>
row.textContent?.includes(texts.prompt),
);
const assistant = Array.from(thread.querySelectorAll(".chat-group.assistant")).find(
(row) => row.textContent?.includes(texts.finalText),
);
return Boolean(
user &&
assistant &&
user.compareDocumentPosition(assistant) & Node.DOCUMENT_POSITION_FOLLOWING,
);
},
{ finalText, prompt },
),
)
.toBe(true);
await gateway.resolveDeferred("chat.history", {
messages: [userMessage, assistantMessage],
sessionId: "control-ui-e2e-session",
thinkingLevel: null,
});
await expect
.poll(() => page.locator(".chat-group.user", { hasText: prompt }).count())
.toBe(1);
} finally {
await suite.closeBrowserContext(context);
}
});
await expect
.poll(() =>
page.locator(".chat-thread-inner").evaluate(
(thread, texts) => {
const user = Array.from(thread.querySelectorAll(".chat-group.user")).find((row) =>
row.textContent?.includes(texts.prompt),
);
const assistant = Array.from(thread.querySelectorAll(".chat-group.assistant")).find(
(row) => row.textContent?.includes(texts.finalText),
);
return Boolean(
user &&
assistant &&
user.compareDocumentPosition(assistant) & Node.DOCUMENT_POSITION_FOLLOWING,
);
},
{ finalText, prompt },
),
)
.toBe(true);
await gateway.resolveDeferred("chat.history", {
messages: includesPrompt ? [userMessage, assistantMessage] : [assistantMessage],
sessionId: "control-ui-e2e-session",
thinkingLevel: null,
});
await expect
.poll(() => page.locator(".chat-group.user", { hasText: prompt }).count())
.toBe(1);
} finally {
await suite.closeBrowserContext(context);
}
},
);
it("keeps a browser-local prompt before a clock-skewed Gateway reply", async () => {
const context = await suite.newBrowserContext({

View File

@@ -53,6 +53,7 @@ import { persistChatComposerState } from "./composer-persistence.ts";
import {
isLocallyOptimisticHistoryMessage,
messageDisplaySignature,
preserveLiveAuthoritativeUserMessages,
preserveOptimisticTailMessages,
readTranscriptSequence,
} from "./history-merge.ts";
@@ -1186,6 +1187,7 @@ async function loadChatHistoryUncached(
const previousMessages = state.chatMessages;
const previousPagination = state.chatHistoryPagination;
const previousSessionId = state.currentSessionId ?? null;
const previousDisplayedLeafEntryId = state.chatDisplayedLeafEntryId ?? null;
const previousRunId = state.chatRunId;
recordChatHistoryTiming(state, "start", startedAtMs, {
requestSessionKey: sessionKey,
@@ -1275,11 +1277,24 @@ async function loadChatHistoryUncached(
(message) =>
!isPendingInitialUserMessage(state.initialUserMessage, state, sessionKey, message),
);
state.chatMessages = preserveOptimisticTailMessages(
const preservedOptimisticMessages = preserveOptimisticTailMessages(
authoritativeMessages,
reconciledTerminal.previousMessages,
shouldHideHistoryMessage,
);
const nextDisplayedLeafEntryId = Object.hasOwn(res.sessionInfo ?? {}, "activeLeafEntryId")
? res.sessionInfo?.activeLeafEntryId?.trim() || null
: previousDisplayedLeafEntryId;
const retainsTranscriptIdentity =
(!previousSessionId || !nextSessionId || previousSessionId === nextSessionId) &&
(!previousDisplayedLeafEntryId || previousDisplayedLeafEntryId === nextDisplayedLeafEntryId);
state.chatMessages = retainsTranscriptIdentity
? preserveLiveAuthoritativeUserMessages(
preservedOptimisticMessages,
reconciledTerminal.currentMessages,
shouldHideHistoryMessage,
)
: preservedOptimisticMessages;
if (Object.hasOwn(res.sessionInfo ?? {}, "activeLeafEntryId")) {
state.chatDisplayedLeafEntryId = res.sessionInfo?.activeLeafEntryId?.trim() || null;
}

View File

@@ -37,6 +37,7 @@ import type { ChatPageHost } from "./chat-state-host.ts";
import { requestChatPageUpdate } from "./chat-state-render.ts";
import { resolveChatAgentId, selectedChatSessionRow } from "./chat-state-route.ts";
import { handleBackgroundTasksEvent } from "./components/chat-background-tasks.ts";
import { rememberLiveAuthoritativeUserMessage } from "./history-merge.ts";
import {
reconcileChatRunFromCurrentSessionRow,
reconcileChatRunFromSessionRow,
@@ -114,7 +115,7 @@ function applyLiveUserMessage(state: ChatPageHost, payload: unknown): void {
: null;
const incoming: LiveUserMessageIdentity = {
...sourceIdentity,
id: eventId ?? sourceIdentity.id,
id: sourceIdentity.id ?? eventId,
idempotencyKey: sourceIdentity.idempotencyKey ?? clientRunId,
sequence: eventSequence ?? sourceIdentity.sequence,
};
@@ -136,6 +137,7 @@ function applyLiveUserMessage(state: ChatPageHost, payload: unknown): void {
...(incoming.sequence !== null ? { seq: incoming.sequence } : {}),
},
};
rememberLiveAuthoritativeUserMessage(message);
const incomingText = extractText(sourceMessage);
const existingIndex = state.chatMessages.findIndex((candidate) => {
const existing = readLiveUserMessageIdentity(candidate);

View File

@@ -1,7 +1,11 @@
// @vitest-environment node
// Control UI tests cover history merge behavior.
import { describe, expect, it } from "vitest";
import { preserveOptimisticTailMessages } from "./history-merge.ts";
import {
preserveLiveAuthoritativeUserMessages,
preserveOptimisticTailMessages,
rememberLiveAuthoritativeUserMessage,
} from "./history-merge.ts";
function createHistoryMessage(
role: "assistant" | "user",
@@ -17,6 +21,75 @@ function createHistoryMessage(
};
}
describe("preserveLiveAuthoritativeUserMessages", () => {
it("keeps a gateway-projected user ahead of a later stale-history reply", () => {
const liveUser = createHistoryMessage("user", "shared prompt", {
id: "shared-user",
seq: 1,
});
const reply = createHistoryMessage("assistant", "shared reply", {
id: "shared-reply",
seq: 2,
});
rememberLiveAuthoritativeUserMessage(liveUser);
expect(preserveLiveAuthoritativeUserMessages([reply], [liveUser, reply])).toEqual([
liveUser,
reply,
]);
});
it("adopts the history projection without duplicating an authoritative user identity", () => {
const liveUser = createHistoryMessage("user", "live prompt", {
id: "shared-user",
seq: 1,
});
const persistedUser = createHistoryMessage("user", "persisted prompt", {
id: "shared-user",
seq: 1,
});
rememberLiveAuthoritativeUserMessage(liveUser);
expect(preserveLiveAuthoritativeUserMessages([persistedUser], [liveUser])).toEqual([
persistedUser,
]);
});
it("does not revive an ordinary historical user absent from a new snapshot", () => {
const previousUser = createHistoryMessage("user", "removed prompt", {
id: "removed-user",
seq: 1,
});
const reply = createHistoryMessage("assistant", "remaining reply", {
id: "remaining-reply",
seq: 2,
});
expect(preserveLiveAuthoritativeUserMessages([reply], [previousUser, reply])).toEqual([reply]);
});
it("uses the next authoritative row to place an id-only live prompt", () => {
const liveUser = createHistoryMessage("user", "shared prompt", { id: "shared-user" });
const reply = createHistoryMessage("assistant", "shared reply", { id: "shared-reply" });
rememberLiveAuthoritativeUserMessage(liveUser);
expect(preserveLiveAuthoritativeUserMessages([reply], [liveUser, reply])).toEqual([
liveUser,
reply,
]);
});
it("does not restore a gateway message hidden from the selected transcript", () => {
const liveUser = createHistoryMessage("user", "hidden prompt", {
id: "hidden-user",
seq: 1,
});
rememberLiveAuthoritativeUserMessage(liveUser);
expect(preserveLiveAuthoritativeUserMessages([], [liveUser], () => true)).toEqual([]);
});
});
describe("preserveOptimisticTailMessages", () => {
it("keeps optimistic tail messages while history is stale", () => {
const persistedUser = createHistoryMessage("user", "first", { seq: 1 });

View File

@@ -19,6 +19,8 @@ type IndexedHistoryMessage = {
type HistoryMessageIndex = Map<string, IndexedHistoryMessage[]>;
const liveAuthoritativeUserMessages = new WeakSet<object>();
function readTranscriptMetadata(message: unknown): Record<string, unknown> | null {
if (!message || typeof message !== "object" || Array.isArray(message)) {
return null;
@@ -224,6 +226,64 @@ function findTranscriptHistoryAnchor(
return sameInstance ?? (entries.length === 1 ? (entries[0] ?? null) : null);
}
export function rememberLiveAuthoritativeUserMessage(message: unknown): void {
if (!message || typeof message !== "object" || Array.isArray(message)) {
return;
}
const identity = readTranscriptMessageIdentity(message);
if (identity.role === "user" && (identity.id !== null || identity.sequence !== null)) {
liveAuthoritativeUserMessages.add(message);
}
}
export function preserveLiveAuthoritativeUserMessages(
historyMessages: unknown[],
currentMessages: unknown[],
shouldHideMessage: (message: unknown) => boolean = () => false,
): unknown[] {
let preservedMessages = historyMessages;
for (let currentIndex = 0; currentIndex < currentMessages.length; currentIndex += 1) {
const message = currentMessages[currentIndex];
if (
!message ||
typeof message !== "object" ||
!liveAuthoritativeUserMessages.has(message) ||
shouldHideMessage(message)
) {
continue;
}
const historyIndex = createHistoryMessageIndex(preservedMessages, shouldHideMessage);
if (findTranscriptHistoryAnchor(historyIndex, message)) {
continue;
}
const sequence = readTranscriptSequence(message);
let insertionIndex =
sequence === null
? -1
: preservedMessages.findIndex((candidate) => {
const candidateSequence = readTranscriptSequence(candidate);
return candidateSequence !== null && candidateSequence > sequence;
});
if (insertionIndex < 0 && sequence === null) {
for (const nextMessage of currentMessages.slice(currentIndex + 1)) {
const anchor = findTranscriptHistoryAnchor(historyIndex, nextMessage);
if (anchor) {
insertionIndex = anchor.index;
break;
}
}
}
// Only a gateway-projected, identity-backed prompt may outlive a stale
// snapshot; transcript sequence or the next known row keeps its reply after it.
preservedMessages = preservedMessages.toSpliced(
insertionIndex < 0 ? preservedMessages.length : insertionIndex,
0,
message,
);
}
return preservedMessages;
}
function findOptimisticHistoryMatch(
historyIndex: HistoryMessageIndex,
identity: TranscriptMessageIdentity,