Skip to content

Commit cf5c994

Browse files
authored
fix(web): keep agent panel rows stable (#5569)
1 parent 2288d41 commit cf5c994

5 files changed

Lines changed: 369 additions & 144 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import {
2+
EventId,
3+
ProviderDriverKind,
4+
RuntimeTaskId,
5+
ThreadId,
6+
type ProviderRuntimeEvent,
7+
} from "@t3tools/contracts";
8+
import { describe, expect, it } from "vite-plus/test";
9+
10+
import { runtimeEventToActivities } from "./ProviderRuntimeIngestion.ts";
11+
12+
const base = {
13+
provider: ProviderDriverKind.make("codex"),
14+
createdAt: "2026-08-06T00:00:00.000Z",
15+
threadId: ThreadId.make("thread-1"),
16+
};
17+
18+
describe("runtimeEventToActivities task progress", () => {
19+
it("persists usage independently from replaceable activity", () => {
20+
const taskId = RuntimeTaskId.make("agent-1");
21+
const usageOnly = {
22+
...base,
23+
type: "task.progress",
24+
eventId: EventId.make("evt-usage"),
25+
payload: {
26+
taskId,
27+
description: "Agent one",
28+
typedUsage: { totalTokens: 73_700_000 },
29+
},
30+
} satisfies ProviderRuntimeEvent;
31+
const command = {
32+
...base,
33+
type: "task.progress",
34+
eventId: EventId.make("evt-command"),
35+
payload: {
36+
taskId,
37+
description: "Agent one",
38+
summary: "Running tests",
39+
lastToolName: "exec_command",
40+
},
41+
} satisfies ProviderRuntimeEvent;
42+
43+
const usageActivities = runtimeEventToActivities(usageOnly);
44+
const commandActivities = runtimeEventToActivities(command);
45+
46+
expect(usageActivities.map((activity) => activity.id)).toEqual(["task-usage:thread-1:agent-1"]);
47+
expect(commandActivities.map((activity) => activity.id)).toEqual([
48+
"task-progress:thread-1:agent-1",
49+
]);
50+
const usagePayload = usageActivities[0]?.payload as Record<string, unknown> | undefined;
51+
expect(usagePayload?.typedUsage).toEqual({ totalTokens: 73_700_000 });
52+
expect(usagePayload?.usageSnapshot).toBe(true);
53+
});
54+
55+
it("splits combined progress and usage into their independent snapshots", () => {
56+
const event = {
57+
...base,
58+
type: "task.progress",
59+
eventId: EventId.make("evt-combined"),
60+
payload: {
61+
taskId: RuntimeTaskId.make("agent-2"),
62+
description: "Agent two",
63+
summary: "Inspecting the panel",
64+
typedUsage: { totalTokens: 4_200, toolUses: 7 },
65+
status: "running",
66+
},
67+
} satisfies ProviderRuntimeEvent;
68+
69+
const activities = runtimeEventToActivities(event);
70+
const progressPayload = activities[0]?.payload as Record<string, unknown>;
71+
const usagePayload = activities[1]?.payload as Record<string, unknown>;
72+
73+
expect(activities.map((activity) => activity.id)).toEqual([
74+
"task-progress:thread-1:agent-2",
75+
"task-usage:thread-1:agent-2",
76+
]);
77+
expect(progressPayload.summary).toBe("Inspecting the panel");
78+
expect(progressPayload.status).toBe("running");
79+
expect(progressPayload).not.toHaveProperty("typedUsage");
80+
expect(usagePayload.typedUsage).toEqual({ totalTokens: 4_200, toolUses: 7 });
81+
expect(usagePayload.usageSnapshot).toBe(true);
82+
expect(usagePayload).not.toHaveProperty("status");
83+
});
84+
});

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts

Lines changed: 73 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -563,39 +563,80 @@ export function runtimeEventToActivities(
563563
}
564564

565565
case "task.progress": {
566+
const linkage = taskLinkageActivityFields(event.payload as Record<string, unknown>);
567+
// Usage and activity are independent latest-state streams. Keeping them
568+
// under separate stable ids prevents a command/reasoning update from
569+
// replacing the last known token count (and prevents a usage-only tick
570+
// from blanking the last meaningful activity).
571+
const identityLinkage = { ...linkage };
572+
delete identityLinkage.typedUsage;
573+
delete identityLinkage.status;
574+
delete identityLinkage.error;
575+
const title =
576+
event.payload.description.trim().length > 0
577+
? { title: truncateDetail(event.payload.description, 120) }
578+
: {};
579+
const hasProgressState =
580+
event.payload.typedUsage === undefined ||
581+
event.payload.summary !== undefined ||
582+
event.payload.lastToolName !== undefined ||
583+
event.payload.status !== undefined ||
584+
event.payload.error !== undefined;
566585
return [
567-
{
568-
// Stable per-task id: progress is "latest state", not history, so
569-
// each tick REPLACES the last via the activity upsert (PK + the
570-
// replace-by-id apply in projector and client reducer). Keeps one
571-
// progress row per task instead of thousands, so a large fleet's
572-
// ticks can no longer evict its own start/terminal rows out of
573-
// the 500-row retention window. Thread-scoped: activity_id is a
574-
// GLOBAL primary key and Claude task ids are session-local, so a
575-
// bare taskId could collide across threads and steal another
576-
// thread's row (review finding).
577-
id: EventId.make(`task-progress:${event.threadId}:${event.payload.taskId}`),
578-
createdAt: event.createdAt,
579-
tone: "info",
580-
kind: "task.progress",
581-
summary:
582-
event.payload.description.trim().length > 0
583-
? truncateDetail(event.payload.description, 120)
584-
: "Reasoning update",
585-
payload: {
586-
taskId: event.payload.taskId,
587-
...(event.payload.description.trim().length > 0
588-
? { title: truncateDetail(event.payload.description, 120) }
589-
: {}),
590-
detail: truncateDetail(event.payload.summary ?? event.payload.description),
591-
...(event.payload.summary ? { summary: truncateDetail(event.payload.summary) } : {}),
592-
...(event.payload.lastToolName ? { lastToolName: event.payload.lastToolName } : {}),
593-
...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}),
594-
...taskLinkageActivityFields(event.payload as Record<string, unknown>),
595-
},
596-
turnId: toTurnId(event.turnId) ?? null,
597-
...maybeSequence,
598-
},
586+
...(hasProgressState
587+
? [
588+
{
589+
// Stable per-task id: activity is "latest state", not
590+
// history, so each meaningful tick replaces the last. This
591+
// bounds a large fleet to one activity row per task.
592+
id: EventId.make(`task-progress:${event.threadId}:${event.payload.taskId}`),
593+
createdAt: event.createdAt,
594+
tone: "info" as const,
595+
kind: "task.progress" as const,
596+
summary:
597+
event.payload.description.trim().length > 0
598+
? truncateDetail(event.payload.description, 120)
599+
: "Reasoning update",
600+
payload: {
601+
taskId: event.payload.taskId,
602+
...title,
603+
detail: truncateDetail(event.payload.summary ?? event.payload.description),
604+
...(event.payload.summary
605+
? { summary: truncateDetail(event.payload.summary) }
606+
: {}),
607+
...(event.payload.lastToolName
608+
? { lastToolName: event.payload.lastToolName }
609+
: {}),
610+
...(event.payload.status ? { status: event.payload.status } : {}),
611+
...(event.payload.error ? { error: event.payload.error } : {}),
612+
...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}),
613+
...identityLinkage,
614+
},
615+
turnId: toTurnId(event.turnId) ?? null,
616+
...maybeSequence,
617+
},
618+
]
619+
: []),
620+
...(event.payload.typedUsage !== undefined
621+
? [
622+
{
623+
id: EventId.make(`task-usage:${event.threadId}:${event.payload.taskId}`),
624+
createdAt: event.createdAt,
625+
tone: "info" as const,
626+
kind: "task.progress" as const,
627+
summary: "Task usage updated",
628+
payload: {
629+
taskId: event.payload.taskId,
630+
...title,
631+
...identityLinkage,
632+
usageSnapshot: true,
633+
typedUsage: event.payload.typedUsage,
634+
},
635+
turnId: toTurnId(event.turnId) ?? null,
636+
...maybeSequence,
637+
},
638+
]
639+
: []),
599640
];
600641
}
601642

0 commit comments

Comments
 (0)