fix: coalesce repeated idle TUI abort notices (#85167)

This commit is contained in:
Dallin Romney
2026-05-21 18:57:56 -07:00
committed by GitHub
parent 577e64db63
commit c8a35c4645
5 changed files with 110 additions and 16 deletions

View File

@@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai
- Node/Linux: keep `OPENCLAW_GATEWAY_TOKEN` out of generated systemd unit files by writing node service token values to a node-specific env file. (#84408)
- Memory-core/dreaming: reuse stable narrative subagent session keys per workspace and phase while keeping per-run idempotency and bounded cleanup, so stale `dreaming-narrative-*` sessions do not accumulate. Fixes #68252, #69187, and #70402. (#70464) Thanks @chiyouYCH.
- Trajectory/support: tolerate partial skill snapshot entries when building support metadata so rejected skill path scans no longer abort trajectory capture. (#71185) Thanks @lukeboyett.
- TUI: coalesce repeated idle Esc abort notices into a single `no active run xN` system row instead of appending duplicate rows.
- Telegram: honor `channels.telegram.pollingStallThresholdMs` in the default isolated polling path, restarting silent workers instead of leaving inbound updates wedged. Fixes #83950. (#84861) Thanks @joshavant.
- Slack: suppress reasoning payloads before reply delivery and dispatch accounting, so Slack monitor, slash-command, fallback, and direct reply paths do not leak model reasoning. Fixes #84319. (#84322) Thanks @ffluk3 and @joshavant.
- Slack: deliver native plugin approval prompts and updates when Slack native approvals are enabled, while keeping plugin approval authorization separate from exec approvers.

View File

@@ -15,6 +15,39 @@ describe("ChatLog", () => {
expect(rendered).not.toContain("system-1");
});
it("coalesces consecutive repeatable system messages", () => {
const chatLog = new ChatLog(20);
chatLog.addSystem("no active run", { coalesceConsecutive: true });
chatLog.addSystem("no active run", { coalesceConsecutive: true });
chatLog.addSystem("no active run", { coalesceConsecutive: true });
const rendered = normalizeTestText(chatLog.render(120).join("\n"));
expect(chatLog.children.length).toBe(1);
expect(rendered).toContain("no active run x3");
});
it("does not coalesce ordinary system messages", () => {
const chatLog = new ChatLog(20);
chatLog.addSystem("status unchanged");
chatLog.addSystem("status unchanged");
expect(chatLog.children.length).toBe(2);
});
it("starts a new repeatable system message after other chat content", () => {
const chatLog = new ChatLog(20);
chatLog.addSystem("no active run", { coalesceConsecutive: true });
chatLog.addUser("hello");
chatLog.addSystem("no active run", { coalesceConsecutive: true });
const rendered = normalizeTestText(chatLog.render(120).join("\n"));
expect(chatLog.children.length).toBe(3);
expect(rendered).not.toContain("no active run x2");
});
it("drops stale streaming references when old components are pruned", () => {
const chatLog = new ChatLog(20);
chatLog.startAssistant("first", "run-1");

View File

@@ -8,6 +8,13 @@ import { UserMessageComponent } from "./user-message.js";
const PENDING_HISTORY_CLOCK_SKEW_TOLERANCE_MS = 60_000;
type RepeatableSystemMessage = {
component: Container;
textNode: Text;
baseText: string;
count: number;
};
export class ChatLog extends Container {
private readonly maxComponents: number;
private toolById = new Map<string, ToolExecutionComponent>();
@@ -22,6 +29,7 @@ export class ChatLog extends Container {
>();
private btwMessage: BtwInlineMessage | null = null;
private toolsExpanded = false;
private repeatableSystemMessage: RepeatableSystemMessage | null = null;
constructor(maxComponents = 180) {
super();
@@ -47,6 +55,9 @@ export class ChatLog extends Container {
if (this.btwMessage === component) {
this.btwMessage = null;
}
if (this.repeatableSystemMessage?.component === component) {
this.repeatableSystemMessage = null;
}
}
private pruneOverflow() {
@@ -65,11 +76,17 @@ export class ChatLog extends Container {
this.pruneOverflow();
}
private appendNonSystem(component: Component) {
this.repeatableSystemMessage = null;
this.append(component);
}
clearAll(opts?: { preservePendingUsers?: boolean }) {
this.clear();
this.toolById.clear();
this.streamingRuns.clear();
this.btwMessage = null;
this.repeatableSystemMessage = null;
if (!opts?.preservePendingUsers) {
this.pendingUsers.clear();
}
@@ -80,7 +97,7 @@ export class ChatLog extends Container {
if (this.children.includes(entry.component)) {
continue;
}
this.append(entry.component);
this.appendNonSystem(entry.component);
}
}
@@ -91,19 +108,42 @@ export class ChatLog extends Container {
this.pendingUsers.clear();
}
private createSystemMessage(text: string): Container {
const entry = new Container();
entry.addChild(new Spacer(1));
entry.addChild(new Text(theme.system(text), 1, 0));
return entry;
private formatRepeatedSystemText(text: string, count: number) {
return count > 1 ? `${text} x${count}` : text;
}
addSystem(text: string) {
this.append(this.createSystemMessage(text));
private createSystemMessage(text: string): RepeatableSystemMessage {
const entry = new Container();
const textNode = new Text(theme.system(text), 1, 0);
entry.addChild(new Spacer(1));
entry.addChild(textNode);
return {
component: entry,
textNode,
baseText: text,
count: 1,
};
}
addSystem(text: string, opts?: { coalesceConsecutive?: boolean }) {
if (
opts?.coalesceConsecutive &&
this.repeatableSystemMessage?.baseText === text &&
this.children[this.children.length - 1] === this.repeatableSystemMessage.component
) {
this.repeatableSystemMessage.count += 1;
this.repeatableSystemMessage.textNode.setText(
theme.system(this.formatRepeatedSystemText(text, this.repeatableSystemMessage.count)),
);
return;
}
const message = this.createSystemMessage(text);
this.append(message.component);
this.repeatableSystemMessage = opts?.coalesceConsecutive ? message : null;
}
addUser(text: string) {
this.append(new UserMessageComponent(text));
this.appendNonSystem(new UserMessageComponent(text));
}
addPendingUser(runId: string, text: string, createdAt = Date.now()) {
@@ -116,7 +156,7 @@ export class ChatLog extends Container {
}
const component = new UserMessageComponent(text);
this.pendingUsers.set(runId, { component, text, createdAt });
this.append(component);
this.appendNonSystem(component);
return component;
}
@@ -192,7 +232,7 @@ export class ChatLog extends Container {
}
const component = new AssistantMessageComponent(text);
this.streamingRuns.set(effectiveRunId, component);
this.append(component);
this.appendNonSystem(component);
return component;
}
@@ -214,7 +254,7 @@ export class ChatLog extends Container {
this.streamingRuns.delete(effectiveRunId);
return;
}
this.append(new AssistantMessageComponent(text));
this.appendNonSystem(new AssistantMessageComponent(text));
}
dropAssistant(runId?: string) {
@@ -232,13 +272,13 @@ export class ChatLog extends Container {
this.btwMessage.setResult(params);
if (this.children[this.children.length - 1] !== this.btwMessage) {
this.removeChild(this.btwMessage);
this.append(this.btwMessage);
this.appendNonSystem(this.btwMessage);
}
return this.btwMessage;
}
const component = new BtwInlineMessage(params);
this.btwMessage = component;
this.append(component);
this.appendNonSystem(component);
return component;
}
@@ -263,7 +303,7 @@ export class ChatLog extends Container {
const component = new ToolExecutionComponent(toolName, args);
component.setExpanded(this.toolsExpanded);
this.toolById.set(toolCallId, component);
this.append(component);
this.appendNonSystem(component);
return component;
}

View File

@@ -386,6 +386,26 @@ describe("tui session actions", () => {
expect(setActivityStatus).toHaveBeenCalledWith("aborted");
});
it("coalesces repeated no-active-run abort notices", async () => {
const addSystem = vi.fn();
const requestRender = vi.fn();
const { abortActive } = createTestSessionActions({
chatLog: {
addSystem,
clearAll: vi.fn(),
} as unknown as import("./components/chat-log.js").ChatLog,
tui: { requestRender } as unknown as import("@earendil-works/pi-tui").TUI,
});
await abortActive();
expect(addSystem).toHaveBeenCalledWith("no active run", {
coalesceConsecutive: true,
});
expect(requestRender).toHaveBeenCalledOnce();
});
it("remembers the selected session after history loads", async () => {
const listSessions = vi.fn().mockResolvedValue({
ts: Date.now(),

View File

@@ -397,7 +397,7 @@ export function createSessionActions(context: SessionActionContext) {
const abortActive = async () => {
const runId = state.activeChatRunId ?? state.pendingChatRunId ?? null;
if (!runId) {
chatLog.addSystem("no active run");
chatLog.addSystem("no active run", { coalesceConsecutive: true });
tui.requestRender();
return;
}