Skip to content

Commit 77756d5

Browse files
feat(code): add cloud progress card for task lifecycle events (#1735)
1 parent 723c58c commit 77756d5

6 files changed

Lines changed: 589 additions & 57 deletions

File tree

apps/code/src/renderer/features/sessions/components/buildConversationItems.test.ts

Lines changed: 284 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,66 @@
11
import type { AcpMessage } from "@shared/types/session-events";
22
import { makeAttachmentUri } from "@utils/promptContent";
33
import { describe, expect, it } from "vitest";
4-
import { buildConversationItems } from "./buildConversationItems";
4+
import {
5+
buildConversationItems,
6+
type ConversationItem,
7+
} from "./buildConversationItems";
8+
9+
function consoleMsg(ts: number, message: string, level = "info"): AcpMessage {
10+
return {
11+
type: "acp_message",
12+
ts,
13+
message: {
14+
jsonrpc: "2.0",
15+
method: "_posthog/console",
16+
params: { level, message },
17+
},
18+
};
19+
}
20+
21+
function progressMsg(
22+
ts: number,
23+
step: string,
24+
status: string,
25+
label: string,
26+
detail?: string,
27+
group = "setup",
28+
): AcpMessage {
29+
return {
30+
type: "acp_message",
31+
ts,
32+
message: {
33+
jsonrpc: "2.0",
34+
method: "_posthog/progress",
35+
params: { step, status, label, detail, group },
36+
},
37+
};
38+
}
39+
40+
function userPromptMsg(ts: number, id: number, text: string): AcpMessage {
41+
return {
42+
type: "acp_message",
43+
ts,
44+
message: {
45+
jsonrpc: "2.0",
46+
id,
47+
method: "session/prompt",
48+
params: { prompt: [{ type: "text", text }] },
49+
},
50+
};
51+
}
52+
53+
function promptResponseMsg(ts: number, id: number): AcpMessage {
54+
return {
55+
type: "acp_message",
56+
ts,
57+
message: {
58+
jsonrpc: "2.0",
59+
id,
60+
result: { stopReason: "end_turn" },
61+
},
62+
};
63+
}
564

665
describe("buildConversationItems", () => {
766
it("extracts cloud prompt attachments into user messages", () => {
@@ -137,4 +196,228 @@ describe("buildConversationItems", () => {
137196
},
138197
]);
139198
});
199+
200+
describe("progress notifications", () => {
201+
it("aggregates progress events arriving before the first prompt into one progress_group item in arrival order", () => {
202+
const events: AcpMessage[] = [
203+
progressMsg(1, "sandbox", "in_progress", "Setting up sandbox"),
204+
progressMsg(2, "sandbox", "completed", "Set up sandbox"),
205+
progressMsg(3, "clone", "in_progress", "Cloning repository"),
206+
progressMsg(4, "clone", "completed", "Cloned repository"),
207+
progressMsg(5, "checkout", "in_progress", "Checking out branch main"),
208+
];
209+
210+
const result = buildConversationItems(events, null);
211+
212+
const groups = findProgressGroups(result.items);
213+
expect(groups).toHaveLength(1);
214+
const update = groups[0];
215+
expect(update.steps.map((s) => [s.key, s.status, s.label])).toEqual([
216+
["sandbox", "completed", "Set up sandbox"],
217+
["clone", "completed", "Cloned repository"],
218+
["checkout", "in_progress", "Checking out branch main"],
219+
]);
220+
expect(update.isActive).toBe(true);
221+
});
222+
223+
it("marks the progress group inactive once no step is in_progress", () => {
224+
const events: AcpMessage[] = [
225+
progressMsg(1, "sandbox", "completed", "Set up sandbox"),
226+
progressMsg(2, "clone", "completed", "Cloned repository"),
227+
progressMsg(3, "agent", "completed", "Started agent"),
228+
];
229+
230+
const result = buildConversationItems(events, null);
231+
const [group] = findProgressGroups(result.items);
232+
expect(group.isActive).toBe(false);
233+
});
234+
235+
it("opens a separate progress_group per group id — distinct groups coexist inline", () => {
236+
const events: AcpMessage[] = [
237+
// Pre-prompt setup group.
238+
progressMsg(
239+
1,
240+
"sandbox",
241+
"in_progress",
242+
"Setting up sandbox",
243+
undefined,
244+
"setup",
245+
),
246+
progressMsg(
247+
2,
248+
"sandbox",
249+
"completed",
250+
"Set up sandbox",
251+
undefined,
252+
"setup",
253+
),
254+
// First user prompt + response.
255+
userPromptMsg(10, 1, "hi"),
256+
promptResponseMsg(20, 1),
257+
// A distinct group id — must open its own card, not join "setup".
258+
progressMsg(
259+
30,
260+
"push",
261+
"in_progress",
262+
"Creating pull request",
263+
undefined,
264+
"pr_create",
265+
),
266+
progressMsg(
267+
40,
268+
"push",
269+
"completed",
270+
"Created pull request",
271+
undefined,
272+
"pr_create",
273+
),
274+
];
275+
276+
const result = buildConversationItems(events, null);
277+
const groups = findProgressGroups(result.items);
278+
expect(groups).toHaveLength(2);
279+
280+
expect(groups[0].steps.map((s) => s.key)).toEqual(["sandbox"]);
281+
expect(groups[0].isActive).toBe(false);
282+
283+
expect(groups[1].steps.map((s) => [s.key, s.status, s.label])).toEqual([
284+
["push", "completed", "Created pull request"],
285+
]);
286+
expect(groups[1].isActive).toBe(false);
287+
});
288+
289+
it("late completion events update the original group regardless of turn boundaries", () => {
290+
const events: AcpMessage[] = [
291+
// `sandbox` starts in the pre-prompt implicit turn.
292+
progressMsg(
293+
1,
294+
"sandbox",
295+
"in_progress",
296+
"Setting up sandbox",
297+
undefined,
298+
"setup",
299+
),
300+
// User prompt + response come in before the completion lands.
301+
userPromptMsg(10, 1, "hi"),
302+
promptResponseMsg(20, 1),
303+
// The completion arrives late, after the turn boundary — it should
304+
// still update the existing "setup" card, not open a new one.
305+
progressMsg(
306+
30,
307+
"sandbox",
308+
"completed",
309+
"Set up sandbox",
310+
undefined,
311+
"setup",
312+
),
313+
];
314+
315+
const result = buildConversationItems(events, null);
316+
const groups = findProgressGroups(result.items);
317+
expect(groups).toHaveLength(1);
318+
expect(groups[0].steps).toEqual([
319+
{
320+
key: "sandbox",
321+
status: "completed",
322+
label: "Set up sandbox",
323+
detail: undefined,
324+
},
325+
]);
326+
expect(groups[0].isActive).toBe(false);
327+
});
328+
329+
it("drops progress events missing a group id", () => {
330+
const events: AcpMessage[] = [
331+
{
332+
type: "acp_message",
333+
ts: 1,
334+
message: {
335+
jsonrpc: "2.0",
336+
method: "_posthog/progress",
337+
params: {
338+
step: "sandbox",
339+
status: "in_progress",
340+
label: "Setting up sandbox",
341+
},
342+
},
343+
},
344+
];
345+
346+
const result = buildConversationItems(events, null);
347+
expect(findProgressGroups(result.items)).toHaveLength(0);
348+
});
349+
350+
it("replaces the step entry when a later event revisits the same key with a new label/status", () => {
351+
const events: AcpMessage[] = [
352+
progressMsg(1, "sandbox", "in_progress", "Setting up sandbox"),
353+
progressMsg(2, "sandbox", "failed", "Set up failed", "timeout"),
354+
];
355+
356+
const result = buildConversationItems(events, null);
357+
const [group] = findProgressGroups(result.items);
358+
expect(group.steps).toHaveLength(1);
359+
expect(group.steps[0]).toEqual({
360+
key: "sandbox",
361+
status: "failed",
362+
label: "Set up failed",
363+
detail: "timeout",
364+
});
365+
});
366+
367+
it("hides debug-level console logs by default and renders them inline when showDebugLogs is true", () => {
368+
const events: AcpMessage[] = [
369+
progressMsg(1, "sandbox", "in_progress", "Setting up sandbox"),
370+
consoleMsg(2, "sandbox provisioned", "debug"),
371+
];
372+
373+
const hidden = buildConversationItems(events, null);
374+
expect(
375+
hidden.items.some(
376+
(i) =>
377+
i.type === "session_update" && i.update.sessionUpdate === "console",
378+
),
379+
).toBe(false);
380+
381+
const shown = buildConversationItems(events, null, {
382+
showDebugLogs: true,
383+
});
384+
expect(
385+
shown.items.some(
386+
(i) =>
387+
i.type === "session_update" && i.update.sessionUpdate === "console",
388+
),
389+
).toBe(true);
390+
});
391+
392+
it("emits no progress group for a conversation without progress notifications", () => {
393+
const events: AcpMessage[] = [userPromptMsg(1, 1, "hi")];
394+
395+
const result = buildConversationItems(events, null);
396+
expect(findProgressGroups(result.items)).toHaveLength(0);
397+
});
398+
});
140399
});
400+
401+
// Local alias kept intentionally narrow to the shape we care about in tests.
402+
type RenderItemUnion = Extract<
403+
ConversationItem,
404+
{ type: "session_update" }
405+
>["update"];
406+
407+
type ProgressGroupUpdate = Extract<
408+
RenderItemUnion,
409+
{ sessionUpdate: "progress_group" }
410+
>;
411+
412+
function findProgressGroups(items: ConversationItem[]): ProgressGroupUpdate[] {
413+
const groups: ProgressGroupUpdate[] = [];
414+
for (const item of items) {
415+
if (
416+
item.type === "session_update" &&
417+
item.update.sessionUpdate === "progress_group"
418+
) {
419+
groups.push(item.update);
420+
}
421+
}
422+
return groups;
423+
}

0 commit comments

Comments
 (0)