authorgravatar for Noojuno@users.noreply.github.comJono Kemball <Noojuno@users.noreply.github.com> 2026-03-13 07:12:18+13:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-12 11:12:18-07:00
loged4be2c849c56c105dd7ccef6b3b5908ebbc2acd
treeb4b330bfbff51107d51b4fef1af180bb1dc577c0
parent5e23e9c7c744724b3d17d4562aa01ce64d25066b
signature Signed by PGP key B5690EEEBB952194

fix: Fix response duration for agent to no longer always be 1ms (#866)


3 files changed, 169 insertions(+), 2 deletions(-)

apps/web/src/components/chat/MessagesTimeline.logic.test.ts created+135
......@@ -0,0 +1,135 @@
1import { describe, expect, it } from "vitest";
2import { computeMessageDurationStart } from "./MessagesTimeline.logic";
3
4describe("computeMessageDurationStart", () => {
5 it("returns message createdAt when there is no preceding user message", () => {
6 const result = computeMessageDurationStart([
7 {
8 id: "a1",
9 role: "assistant",
10 createdAt: "2026-01-01T00:00:05Z",
11 completedAt: "2026-01-01T00:00:10Z",
12 },
13 ]);
14 expect(result).toEqual(new Map([["a1", "2026-01-01T00:00:05Z"]]));
15 });
16
17 it("uses the user message createdAt for the first assistant response", () => {
18 const result = computeMessageDurationStart([
19 { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" },
20 {
21 id: "a1",
22 role: "assistant",
23 createdAt: "2026-01-01T00:00:30Z",
24 completedAt: "2026-01-01T00:00:30Z",
25 },
26 ]);
27
28 expect(result).toEqual(
29 new Map([
30 ["u1", "2026-01-01T00:00:00Z"],
31 ["a1", "2026-01-01T00:00:00Z"],
32 ]),
33 );
34 });
35
36 it("uses the previous assistant completedAt for subsequent assistant responses", () => {
37 const result = computeMessageDurationStart([
38 { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" },
39 {
40 id: "a1",
41 role: "assistant",
42 createdAt: "2026-01-01T00:00:30Z",
43 completedAt: "2026-01-01T00:00:30Z",
44 },
45 {
46 id: "a2",
47 role: "assistant",
48 createdAt: "2026-01-01T00:00:55Z",
49 completedAt: "2026-01-01T00:00:55Z",
50 },
51 ]);
52
53 expect(result).toEqual(
54 new Map([
55 ["u1", "2026-01-01T00:00:00Z"],
56 ["a1", "2026-01-01T00:00:00Z"],
57 ["a2", "2026-01-01T00:00:30Z"],
58 ]),
59 );
60 });
61
62 it("does not advance the boundary for a streaming message without completedAt", () => {
63 const result = computeMessageDurationStart([
64 { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" },
65 { id: "a1", role: "assistant", createdAt: "2026-01-01T00:00:30Z" },
66 {
67 id: "a2",
68 role: "assistant",
69 createdAt: "2026-01-01T00:00:55Z",
70 completedAt: "2026-01-01T00:00:55Z",
71 },
72 ]);
73
74 expect(result).toEqual(
75 new Map([
76 ["u1", "2026-01-01T00:00:00Z"],
77 ["a1", "2026-01-01T00:00:00Z"],
78 ["a2", "2026-01-01T00:00:00Z"],
79 ]),
80 );
81 });
82
83 it("resets the boundary on a new user message", () => {
84 const result = computeMessageDurationStart([
85 { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" },
86 {
87 id: "a1",
88 role: "assistant",
89 createdAt: "2026-01-01T00:00:30Z",
90 completedAt: "2026-01-01T00:00:30Z",
91 },
92 { id: "u2", role: "user", createdAt: "2026-01-01T00:01:00Z" },
93 {
94 id: "a2",
95 role: "assistant",
96 createdAt: "2026-01-01T00:01:20Z",
97 completedAt: "2026-01-01T00:01:20Z",
98 },
99 ]);
100
101 expect(result).toEqual(
102 new Map([
103 ["u1", "2026-01-01T00:00:00Z"],
104 ["a1", "2026-01-01T00:00:00Z"],
105 ["u2", "2026-01-01T00:01:00Z"],
106 ["a2", "2026-01-01T00:01:00Z"],
107 ]),
108 );
109 });
110
111 it("handles system messages without affecting the boundary", () => {
112 const result = computeMessageDurationStart([
113 { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" },
114 { id: "s1", role: "system", createdAt: "2026-01-01T00:00:01Z" },
115 {
116 id: "a1",
117 role: "assistant",
118 createdAt: "2026-01-01T00:00:30Z",
119 completedAt: "2026-01-01T00:00:30Z",
120 },
121 ]);
122
123 expect(result).toEqual(
124 new Map([
125 ["u1", "2026-01-01T00:00:00Z"],
126 ["s1", "2026-01-01T00:00:00Z"],
127 ["a1", "2026-01-01T00:00:00Z"],
128 ]),
129 );
130 });
131
132 it("returns empty map for empty input", () => {
133 expect(computeMessageDurationStart([])).toEqual(new Map());
134 });
135});
apps/web/src/components/chat/MessagesTimeline.logic.ts created+25
......@@ -0,0 +1,25 @@
1export interface TimelineDurationMessage {
2 id: string;
3 role: "user" | "assistant" | "system";
4 createdAt: string;
5 completedAt?: string | undefined;
6}
7
8export function computeMessageDurationStart(
9 messages: ReadonlyArray<TimelineDurationMessage>,
10): Map<string, string> {
11 const result = new Map<string, string>();
12 let lastBoundary: string | null = null;
13
14 for (const message of messages) {
15 if (message.role === "user") {
16 lastBoundary = message.createdAt;
17 }
18 result.set(message.id, lastBoundary ?? message.createdAt);
19 if (message.role === "assistant" && message.completedAt) {
20 lastBoundary = message.completedAt;
21 }
22 }
23
24 return result;
25}
apps/web/src/components/chat/MessagesTimeline.tsx+9-2
......@@ -19,6 +19,7 @@ import { ProposedPlanCard } from "./ProposedPlanCard";
1919import { ChangedFilesTree } from "./ChangedFilesTree";
2020import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
2121import { MessageCopyButton } from "./MessageCopyButton";
22import { computeMessageDurationStart } from "./MessagesTimeline.logic";
2223
2324const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
2425const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8;
......@@ -98,6 +99,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
9899
99100 const rows = useMemo<TimelineRow[]>(() => {
100101 const nextRows: TimelineRow[] = [];
102 const durationStartByMessageId = computeMessageDurationStart(
103 timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])),
104 );
101105
102106 for (let index = 0; index < timelineEntries.length; index += 1) {
103107 const timelineEntry = timelineEntries[index];
......@@ -139,6 +143,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
139143 id: timelineEntry.id,
140144 createdAt: timelineEntry.createdAt,
141145 message: timelineEntry.message,
146 durationStart:
147 durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt,
142148 showCompletionDivider:
143149 timelineEntry.message.role === "assistant" &&
144150 completionDividerBeforeEntryId === timelineEntry.id,
......@@ -507,8 +513,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
507513 {formatMessageMeta(
508514 row.message.createdAt,
509515 row.message.streaming
510 ? formatElapsed(row.message.createdAt, nowIso)
511 : formatElapsed(row.message.createdAt, row.message.completedAt),
516 ? formatElapsed(row.durationStart, nowIso)
517 : formatElapsed(row.durationStart, row.message.completedAt),
512518 )}
513519 </p>
514520 </div>
......@@ -605,6 +611,7 @@ type TimelineRow =
605611 id: string;
606612 createdAt: string;
607613 message: TimelineMessage;
614 durationStart: string;
608615 showCompletionDivider: boolean;
609616 }
610617 | {