fix: harden compaction status gating (#1466) (thanks @dlauer)

This commit is contained in:
Peter Steinberger 2026-01-23 06:32:17 +00:00
parent fd3c76cad3
commit 2e089ec9a0
6 changed files with 65 additions and 6 deletions

View File

@ -28,6 +28,7 @@ Docs: https://docs.clawd.bot
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
- Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky.
- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
- Compaction: harden oversized summarization handling and gate UI compaction status by verbose level. (#1466) Thanks @dlauer.
- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.
- Agents: surface concrete API error details instead of generic AI service errors.
- Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj.

View File

@ -15,6 +15,15 @@ const TURN_PREFIX_INSTRUCTIONS =
const MAX_TOOL_FAILURES = 8;
const MAX_TOOL_FAILURE_CHARS = 240;
function isAbortError(err: unknown): boolean {
if (!err || typeof err !== "object") return false;
const name = "name" in err ? String(err.name) : "";
if (name === "AbortError") return true;
const message =
"message" in err && typeof err.message === "string" ? err.message.toLowerCase() : "";
return message.includes("aborted");
}
type ToolFailure = {
toolCallId: string;
toolName: string;
@ -251,6 +260,9 @@ async function summarizeWithFallback(params: {
try {
return await summarizeChunks(params);
} catch (fullError) {
if (params.signal.aborted || isAbortError(fullError)) {
throw fullError;
}
console.warn(
`Full summarization failed, trying partial: ${
fullError instanceof Error ? fullError.message : String(fullError)
@ -283,6 +295,9 @@ async function summarizeWithFallback(params: {
const notes = oversizedNotes.length > 0 ? `\n\n${oversizedNotes.join("\n")}` : "";
return partialSummary + notes;
} catch (partialError) {
if (params.signal.aborted || isAbortError(partialError)) {
throw partialError;
}
console.warn(
`Partial summarization also failed: ${
partialError instanceof Error ? partialError.message : String(partialError)

View File

@ -173,7 +173,7 @@ export function createAgentEventHandler({
nodeSendToSession(sessionKey, "chat", payload);
};
const shouldEmitToolEvents = (runId: string, sessionKey?: string) => {
const shouldEmitVerboseEvents = (runId: string, sessionKey?: string) => {
const runContext = getAgentRunContext(runId);
const runVerbose = normalizeVerboseLevel(runContext?.verboseLevel);
if (runVerbose) return runVerbose === "on";
@ -198,7 +198,10 @@ export function createAgentEventHandler({
// Include sessionKey so Control UI can filter tool streams per session.
const agentPayload = sessionKey ? { ...evt, sessionKey } : evt;
const last = agentRunSeq.get(evt.runId) ?? 0;
if (evt.stream === "tool" && !shouldEmitToolEvents(evt.runId, sessionKey)) {
if (
(evt.stream === "tool" || evt.stream === "compaction") &&
!shouldEmitVerboseEvents(evt.runId, sessionKey)
) {
agentRunSeq.set(evt.runId, evt.seq);
return;
}

View File

@ -77,6 +77,22 @@ describe("gateway server agent", () => {
{
registerAgentRunContext("run-tool-off", { sessionKey: "agent:main:main" });
const compactionEvtP = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "agent" &&
o.payload?.runId === "run-tool-off" &&
o.payload?.stream === "compaction",
1000,
);
emitAgentEvent({
runId: "run-tool-off",
stream: "compaction",
data: { phase: "start" },
});
emitAgentEvent({
runId: "run-tool-off",
stream: "tool",
@ -98,6 +114,8 @@ describe("gateway server agent", () => {
? (evt.payload as Record<string, unknown>)
: {};
expect(payload.stream).toBe("assistant");
await expect(compactionEvtP).rejects.toThrow("timeout");
}
{

View File

@ -142,6 +142,7 @@ export type CompactionStatus = {
active: boolean;
startedAt: number | null;
completedAt: number | null;
retryingAt: number | null;
};
type CompactionHost = ToolStreamHost & {
@ -154,6 +155,7 @@ const COMPACTION_TOAST_DURATION_MS = 5000;
export function handleCompactionEvent(host: CompactionHost, payload: AgentEventPayload) {
const data = payload.data ?? {};
const phase = typeof data.phase === "string" ? data.phase : "";
const willRetry = Boolean(data.willRetry);
// Clear any existing timer
if (host.compactionClearTimer != null) {
@ -166,12 +168,14 @@ export function handleCompactionEvent(host: CompactionHost, payload: AgentEventP
active: true,
startedAt: Date.now(),
completedAt: null,
retryingAt: null,
};
} else if (phase === "end") {
host.compactionStatus = {
active: false,
active: willRetry,
startedAt: host.compactionStatus?.startedAt ?? null,
completedAt: Date.now(),
completedAt: willRetry ? null : Date.now(),
retryingAt: willRetry ? Date.now() : null,
};
// Auto-clear the toast after duration
host.compactionClearTimer = window.setTimeout(() => {
@ -183,13 +187,19 @@ export function handleCompactionEvent(host: CompactionHost, payload: AgentEventP
export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPayload) {
if (!payload) return;
// Handle compaction events
if (payload.stream === "compaction") {
const sessionKey =
typeof payload.sessionKey === "string" ? payload.sessionKey : undefined;
if (sessionKey && sessionKey !== host.sessionKey) return;
if (!sessionKey && host.chatRunId && payload.runId !== host.chatRunId) return;
if (host.chatRunId && payload.runId !== host.chatRunId) return;
if (!host.chatRunId) return;
handleCompactionEvent(host as CompactionHost, payload);
return;
}
if (payload.stream !== "tool") return;
const sessionKey =
typeof payload.sessionKey === "string" ? payload.sessionKey : undefined;

View File

@ -20,6 +20,7 @@ export type CompactionIndicatorStatus = {
active: boolean;
startedAt: number | null;
completedAt: number | null;
retryingAt: number | null;
};
export type ChatProps = {
@ -80,6 +81,17 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
`;
}
if (status.retryingAt) {
const elapsed = Date.now() - status.retryingAt;
if (elapsed < COMPACTION_TOAST_DURATION_MS) {
return html`
<div class="callout info compaction-indicator compaction-indicator--active">
🧹 Retrying compaction...
</div>
`;
}
}
// Show "compaction complete" briefly after completion
if (status.completedAt) {
const elapsed = Date.now() - status.completedAt;