Skip to content

Commit e7159d9

Browse files
committed
feat(extension): add context compaction for sidepanel chat
- Add contextCompaction.ts with rolling summary logic and context pressure detection - Add AssistantStatusCard component to surface compaction/retry status in the UI - Add providerOptions.ts and transportErrors.ts for provider configuration and error handling - Add retryMessages utility for reconstructing message state after a retry - Wire rolling summary into chatPool, sessionStorage, and useHuntlyChat - Update SidepanelApp to propagate rollingSummary and expose retryLastRun - Add tests for AssistantStatusCard and context compaction logic
1 parent c6bc7b9 commit e7159d9

17 files changed

Lines changed: 2224 additions & 311 deletions
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/** @jest-environment jsdom */
2+
3+
import { act } from "react-dom/test-utils";
4+
import { createRoot } from "react-dom/client";
5+
6+
import { AssistantStatusCard } from "../sidepanel/components/AssistantStatusCard";
7+
import type { ChatPart } from "../sidepanel/types";
8+
9+
jest.mock("../sidepanel/components/MarkdownContent", () => ({
10+
MarkdownContent: ({ text }: { text: string }) => {
11+
const React = require("react");
12+
return React.createElement("div", null, text);
13+
},
14+
}));
15+
16+
jest.mock("../i18n", () => ({
17+
useI18n: () => ({
18+
t: (key: string) => key,
19+
}),
20+
}));
21+
22+
(
23+
globalThis as typeof globalThis & {
24+
IS_REACT_ACT_ENVIRONMENT?: boolean;
25+
}
26+
).IS_REACT_ACT_ENVIRONMENT = true;
27+
28+
function renderStatusCard(
29+
props: Partial<React.ComponentProps<typeof AssistantStatusCard>> = {}
30+
) {
31+
const container = document.createElement("div");
32+
document.body.appendChild(container);
33+
const root = createRoot(container);
34+
const part: ChatPart = {
35+
type: "status",
36+
statusKind: "error",
37+
errorCode: "context-overflow",
38+
retryable: true,
39+
canCompact: true,
40+
};
41+
42+
act(() => {
43+
root.render(
44+
<AssistantStatusCard
45+
actionable={true}
46+
part={part}
47+
onCompactContext={jest.fn()}
48+
onRetryLastRun={jest.fn()}
49+
{...props}
50+
/>
51+
);
52+
});
53+
54+
return {
55+
container,
56+
cleanup: () => {
57+
act(() => root.unmount());
58+
container.remove();
59+
},
60+
};
61+
}
62+
63+
describe("AssistantStatusCard", () => {
64+
afterEach(() => {
65+
document.body.innerHTML = "";
66+
});
67+
68+
it("disables actions and marks the active retry button busy", () => {
69+
const { container, cleanup } = renderStatusCard({ busyAction: "retry" });
70+
const buttons = Array.from(container.querySelectorAll("button"));
71+
72+
expect(buttons).toHaveLength(2);
73+
expect(buttons.every((button) => button.disabled)).toBe(true);
74+
expect(buttons[0].getAttribute("aria-busy")).toBe("true");
75+
expect(buttons[0].className).toContain("focus-visible:ring-2");
76+
77+
cleanup();
78+
});
79+
80+
it("disables actions and marks the active compact button busy", () => {
81+
const { container, cleanup } = renderStatusCard({ busyAction: "compact" });
82+
const buttons = Array.from(container.querySelectorAll("button"));
83+
84+
expect(buttons).toHaveLength(2);
85+
expect(buttons.every((button) => button.disabled)).toBe(true);
86+
expect(buttons[1].getAttribute("aria-busy")).toBe("true");
87+
88+
cleanup();
89+
});
90+
});
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import type { ChatMessage } from "../sidepanel/types";
2+
import {
3+
buildMessagesForModel,
4+
compactConversation,
5+
} from "../sidepanel/contextCompaction";
6+
import { SessionChatPool } from "../sidepanel/chatPool";
7+
import { prepareMessagesForRetry } from "../sidepanel/utils/retryMessages";
8+
import type { HuntlyUIMessage } from "../sidepanel/useHuntlyChat";
9+
10+
jest.mock("ai", () => ({
11+
ToolLoopAgent: jest.fn(),
12+
convertToModelMessages: jest.fn(),
13+
isDataUIPart: (part: { type?: string }) =>
14+
Boolean(part.type?.startsWith("data-")),
15+
isReasoningUIPart: (part: { type?: string }) => part.type === "reasoning",
16+
isTextUIPart: (part: { type?: string }) => part.type === "text",
17+
isToolUIPart: (part: { type?: string }) =>
18+
Boolean(part.type === "dynamic-tool" || part.type?.startsWith("tool-")),
19+
stepCountIs: jest.fn(),
20+
streamText: jest.fn(),
21+
validateUIMessages: jest.fn(),
22+
}));
23+
24+
jest.mock("@ai-sdk/react", () => {
25+
class FakeChat {
26+
messages: unknown[];
27+
status = "ready";
28+
error: Error | undefined;
29+
30+
constructor({ messages = [] }: { messages?: unknown[] }) {
31+
this.messages = messages;
32+
}
33+
34+
sendMessage = jest.fn(async () => undefined);
35+
regenerate = jest.fn(async () => undefined);
36+
resumeStream = jest.fn(async () => undefined);
37+
stop = jest.fn(async () => undefined);
38+
addToolOutput = jest.fn(async () => undefined);
39+
addToolApprovalResponse = jest.fn(async () => undefined);
40+
clearError = jest.fn(() => {
41+
this.error = undefined;
42+
this.status = "ready";
43+
});
44+
}
45+
46+
return { Chat: FakeChat };
47+
});
48+
49+
jest.mock("../sidepanel/agentTools", () => ({
50+
createAgentToolContext: jest.fn(async () => ({
51+
tools: {},
52+
close: jest.fn(async () => undefined),
53+
})),
54+
formatAgentToolTitle: jest.fn(() => "Tool"),
55+
getAgentToolMetadata: jest.fn(() => undefined),
56+
parseAgentToolTitle: jest.fn(() => null),
57+
}));
58+
59+
jest.mock("../sidepanel/contextCompaction", () => {
60+
const actual = jest.requireActual("../sidepanel/contextCompaction");
61+
return {
62+
...actual,
63+
compactConversation: jest.fn(),
64+
};
65+
});
66+
67+
function userMessage(id: string, text: string): HuntlyUIMessage {
68+
return {
69+
id,
70+
role: "user",
71+
parts: [{ type: "text", text }],
72+
};
73+
}
74+
75+
function assistantMessage(
76+
id: string,
77+
parts: HuntlyUIMessage["parts"]
78+
): HuntlyUIMessage {
79+
return {
80+
id,
81+
role: "assistant",
82+
parts,
83+
};
84+
}
85+
86+
function statusPart(kind: "compacted" | "error") {
87+
return {
88+
type: "data-huntly-status",
89+
id: `status-${kind}`,
90+
data: {
91+
kind,
92+
errorCode: kind === "error" ? "context-overflow" : undefined,
93+
retryable: kind === "error" ? true : undefined,
94+
canCompact: kind === "error" ? true : undefined,
95+
},
96+
} as HuntlyUIMessage["parts"][number];
97+
}
98+
99+
describe("sidepanel context compaction", () => {
100+
it("does not send historical reasoning or Huntly status data to the model", () => {
101+
const result = buildMessagesForModel([
102+
userMessage("user-1", "Question"),
103+
assistantMessage("assistant-1", [
104+
{ type: "reasoning", text: "hidden chain", state: "done" } as any,
105+
statusPart("error"),
106+
{ type: "text", text: "Visible answer", state: "done" } as any,
107+
]),
108+
]).messages;
109+
110+
expect(result.map((message) => message.id)).toEqual([
111+
"user-1",
112+
"assistant-1",
113+
]);
114+
expect(result[1].parts.map((part) => part.type)).toEqual(["text"]);
115+
});
116+
117+
it("uses rolling summary plus only raw messages after the compacted boundary", () => {
118+
const result = buildMessagesForModel(
119+
[
120+
userMessage("user-1", "Old question"),
121+
assistantMessage("assistant-1", [
122+
{ type: "text", text: "Old answer", state: "done" } as any,
123+
]),
124+
userMessage("user-2", "New question"),
125+
assistantMessage("assistant-2", [
126+
{ type: "text", text: "New answer", state: "done" } as any,
127+
]),
128+
],
129+
{
130+
text: "Earlier summary",
131+
summarizedThroughMessageId: "assistant-1",
132+
updatedAt: "2026-04-25T00:00:00.000Z",
133+
version: 1,
134+
}
135+
).messages;
136+
137+
expect(result[0].role).toBe("system");
138+
expect(result[0].parts[0]).toMatchObject({
139+
type: "text",
140+
text: expect.stringContaining("Earlier summary"),
141+
});
142+
expect(result.slice(1).map((message) => message.id)).toEqual([
143+
"user-2",
144+
"assistant-2",
145+
]);
146+
});
147+
});
148+
149+
describe("sidepanel retry cleanup", () => {
150+
it("retries from the last user message instead of continuing persisted status cards", () => {
151+
const retryMessages = prepareMessagesForRetry([
152+
userMessage("user-1", "Try this"),
153+
assistantMessage("compact-status", [statusPart("compacted")]),
154+
assistantMessage("error-status", [statusPart("error")]),
155+
]);
156+
157+
expect(retryMessages.map((message) => message.id)).toEqual(["user-1"]);
158+
});
159+
160+
it("drops a partial failed assistant response before retrying", () => {
161+
const retryMessages = prepareMessagesForRetry([
162+
userMessage("user-1", "First"),
163+
assistantMessage("assistant-1", [
164+
{ type: "text", text: "Complete", state: "done" } as any,
165+
]),
166+
userMessage("user-2", "Second"),
167+
assistantMessage("assistant-2", [
168+
{ type: "text", text: "Partial", state: "done" } as any,
169+
statusPart("error"),
170+
]),
171+
]);
172+
173+
expect(retryMessages.map((message) => message.id)).toEqual([
174+
"user-1",
175+
"assistant-1",
176+
"user-2",
177+
]);
178+
});
179+
});
180+
181+
describe("SessionChatPool manual compact", () => {
182+
beforeEach(() => {
183+
(compactConversation as jest.Mock).mockReset();
184+
});
185+
186+
it("clears chat error state before manual compacting", async () => {
187+
const initialMessages: ChatMessage[] = Array.from(
188+
{ length: 13 },
189+
(_, index) => ({
190+
id: `message-${index}`,
191+
role: index % 2 === 0 ? "user" : "assistant",
192+
parts: [{ type: "text", text: `message ${index}` }],
193+
status: "complete",
194+
})
195+
);
196+
const pool = new SessionChatPool(
197+
() => ({
198+
modelInfo: {
199+
modelId: "test-model",
200+
displayName: "Test Model",
201+
provider: "test",
202+
model: {} as any,
203+
},
204+
systemPrompt: "",
205+
thinkingEnabled: false,
206+
}),
207+
jest.fn()
208+
);
209+
const chat = pool.ensure("session-1", initialMessages);
210+
const clearErrorSpy = jest.spyOn(
211+
chat as unknown as { clearError: () => void },
212+
"clearError"
213+
);
214+
215+
(compactConversation as jest.Mock).mockResolvedValue({
216+
rollingSummary: {
217+
text: "Updated summary",
218+
summarizedThroughMessageId: "message-4",
219+
updatedAt: "2026-04-25T00:00:00.000Z",
220+
version: 1,
221+
},
222+
compactedMessageCount: 5,
223+
compactedThroughMessageId: "message-4",
224+
});
225+
226+
await pool.compact("session-1");
227+
228+
expect(clearErrorSpy).toHaveBeenCalledTimes(1);
229+
expect(compactConversation).toHaveBeenCalledTimes(1);
230+
});
231+
});

0 commit comments

Comments
 (0)