Skip to content

Commit 2d93f7c

Browse files
committed
fix(acp): stabilize streaming timeline UI and stopReason handling
1 parent 3c6c01e commit 2d93f7c

File tree

9 files changed

+376
-88
lines changed

9 files changed

+376
-88
lines changed

src/lib/acp/client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,7 @@ export class ACPClient {
326326
}),
327327
);
328328

329-
this._session.finishAgentTurn();
329+
this._session.finishAgentTurn(result.stopReason);
330330
return result;
331331
} catch (error) {
332332
this._session.finishAgentTurn();

src/lib/acp/history.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
const STORAGE_KEY = "acpSessionHistory";
22
const MAX_ENTRIES = 30;
3+
const VALID_STOP_REASONS = new Set([
4+
"end_turn",
5+
"max_tokens",
6+
"max_turn_requests",
7+
"refusal",
8+
"cancelled",
9+
]);
10+
11+
export interface ACPHistoryTurnStop {
12+
stopReason: string;
13+
timestamp: number;
14+
}
315

416
export interface ACPHistoryEntry {
517
sessionId: string;
@@ -8,6 +20,7 @@ export interface ACPHistoryEntry {
820
agentName: string;
921
title: string;
1022
preview: string;
23+
turnStops: ACPHistoryTurnStop[];
1124
createdAt: string;
1225
updatedAt: string;
1326
}
@@ -27,6 +40,24 @@ type ACPHistorySaveInput = Partial<ACPHistoryEntry> &
2740
function normalizeEntry(entry: Partial<ACPHistoryEntry> = {}): ACPHistoryEntry {
2841
const createdAt = entry.createdAt || new Date().toISOString();
2942
const updatedAt = entry.updatedAt || createdAt;
43+
const turnStops = Array.isArray(entry.turnStops)
44+
? entry.turnStops
45+
.map((item) => {
46+
const stopReason =
47+
typeof item?.stopReason === "string" ? item.stopReason.trim() : "";
48+
if (!VALID_STOP_REASONS.has(stopReason)) return null;
49+
50+
const timestamp = Number.isFinite(item?.timestamp)
51+
? Number(item.timestamp)
52+
: Date.now();
53+
54+
return {
55+
stopReason,
56+
timestamp,
57+
};
58+
})
59+
.filter((item): item is ACPHistoryTurnStop => Boolean(item))
60+
: [];
3061

3162
return {
3263
sessionId:
@@ -37,6 +68,7 @@ function normalizeEntry(entry: Partial<ACPHistoryEntry> = {}): ACPHistoryEntry {
3768
typeof entry.agentName === "string" ? entry.agentName.trim() : "",
3869
title: typeof entry.title === "string" ? entry.title.trim() : "",
3970
preview: typeof entry.preview === "string" ? entry.preview.trim() : "",
71+
turnStops,
4072
createdAt,
4173
updatedAt,
4274
};
@@ -100,6 +132,10 @@ const acpHistory = {
100132
agentName: normalized.agentName || current.agentName,
101133
title: normalized.title || current.title,
102134
preview: normalized.preview || current.preview,
135+
turnStops:
136+
normalized.turnStops.length > 0
137+
? normalized.turnStops
138+
: current.turnStops,
103139
createdAt: current.createdAt,
104140
updatedAt: normalized.updatedAt || new Date().toISOString(),
105141
};

src/lib/acp/models.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type {
22
Plan as ACPPlan,
3+
StopReason as ACPStopReason,
34
ToolCall as ACPToolCall,
45
ContentBlock,
56
PlanEntry,
@@ -40,13 +41,22 @@ export const ConnectionState = {
4041
ERROR: "error",
4142
} as const;
4243

44+
export const StopReason = {
45+
END_TURN: "end_turn",
46+
MAX_TOKENS: "max_tokens",
47+
MAX_TURN_REQUESTS: "max_turn_requests",
48+
REFUSAL: "refusal",
49+
CANCELLED: "cancelled",
50+
} as const;
51+
4352
export type ToolCallStatus =
4453
(typeof ToolCallStatus)[keyof typeof ToolCallStatus];
4554
export type ToolKind = (typeof ToolKind)[keyof typeof ToolKind];
4655
export type PlanEntryStatus =
4756
(typeof PlanEntryStatus)[keyof typeof PlanEntryStatus];
4857
export type ConnectionState =
4958
(typeof ConnectionState)[keyof typeof ConnectionState];
59+
export type StopReason = ACPStopReason;
5060

5161
export interface ToolCall extends ACPToolCall {
5262
timestamp?: number;
@@ -66,6 +76,11 @@ export interface ChatMessage {
6676
streaming?: boolean;
6777
}
6878

79+
export interface TurnStop {
80+
stopReason: StopReason;
81+
timestamp: number;
82+
}
83+
6984
export type TimelineEntry =
7085
| {
7186
entryId: string;
@@ -81,4 +96,9 @@ export type TimelineEntry =
8196
entryId: string;
8297
type: "plan";
8398
plan: Plan;
99+
}
100+
| {
101+
entryId: string;
102+
type: "turn_stop";
103+
turnStop: TurnStop;
84104
};

src/lib/acp/session.ts

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,16 @@ import type {
77
ChatMessage,
88
Plan,
99
PlanEntry,
10+
StopReason,
1011
TimelineEntry,
1112
ToolCall,
13+
TurnStop,
1214
} from "./models";
13-
import { ToolCallStatus } from "./models";
15+
import { StopReason as StopReasons, ToolCallStatus } from "./models";
16+
17+
const STOP_REASON_VALUES = new Set<StopReason>(
18+
Object.values(StopReasons) as StopReason[],
19+
);
1420

1521
type SessionEventType =
1622
| "message_update"
@@ -29,6 +35,7 @@ export class ACPSession {
2935
readonly messages: ChatMessage[] = [];
3036
readonly timeline: TimelineEntry[] = [];
3137
readonly toolCalls: Map<string, ToolCall> = new Map();
38+
readonly turnStops: TurnStop[] = [];
3239
plan: Plan | null = null;
3340
title: string | null = null;
3441
updatedAt: string | null = null;
@@ -128,18 +135,48 @@ export class ACPSession {
128135
return this.pendingPermissions.shift();
129136
}
130137

131-
finishAgentTurn(): void {
138+
finishAgentTurn(stopReason?: StopReason | null): void {
132139
this.closeCurrentAgentMessage();
133140
this.closeCurrentThoughtMessage();
134141
this.closeCurrentUserMessage();
135142
this.currentPlanEntryId = null;
143+
const normalizedStopReason = this.normalizeStopReason(stopReason);
144+
if (this.shouldPersistStopReason(normalizedStopReason)) {
145+
this.appendTurnStop(normalizedStopReason);
146+
}
136147
this.emit("session_end", null);
137148
}
138149

139150
getToolCall(toolCallId: string): ToolCall | undefined {
140151
return this.toolCalls.get(toolCallId);
141152
}
142153

154+
setPersistedTurnStops(turnStops: TurnStop[]): void {
155+
for (let index = this.timeline.length - 1; index >= 0; index--) {
156+
if (this.timeline[index]?.type === "turn_stop") {
157+
this.timeline.splice(index, 1);
158+
}
159+
}
160+
this.turnStops.length = 0;
161+
162+
const normalizedStops = Array.isArray(turnStops)
163+
? turnStops
164+
.map((entry) => {
165+
const stopReason = this.normalizeStopReason(entry?.stopReason);
166+
if (!this.shouldPersistStopReason(stopReason)) return null;
167+
const timestamp = Number.isFinite(entry?.timestamp)
168+
? Number(entry.timestamp)
169+
: Date.now();
170+
return { stopReason, timestamp };
171+
})
172+
.filter((entry): entry is TurnStop => Boolean(entry))
173+
: [];
174+
175+
normalizedStops.forEach((entry) => {
176+
this.appendTurnStop(entry.stopReason, entry.timestamp);
177+
});
178+
}
179+
143180
private handleMessageChunk(
144181
role: ChatMessage["role"],
145182
content: ContentBlock,
@@ -313,6 +350,35 @@ export class ACPSession {
313350
this.emit("timeline_update", entry);
314351
}
315352

353+
private appendTurnStop(
354+
stopReason: StopReason,
355+
timestamp: number = Date.now(),
356+
): void {
357+
const turnStop: TurnStop = {
358+
stopReason,
359+
timestamp,
360+
};
361+
this.turnStops.push(turnStop);
362+
this.pushTimelineEntry({
363+
entryId: this.nextTimelineId(),
364+
type: "turn_stop",
365+
turnStop,
366+
});
367+
}
368+
369+
private normalizeStopReason(value: unknown): StopReason | null {
370+
if (typeof value !== "string") return null;
371+
if (!STOP_REASON_VALUES.has(value as StopReason)) return null;
372+
return value as StopReason;
373+
}
374+
375+
private shouldPersistStopReason(
376+
stopReason: StopReason | null,
377+
): stopReason is StopReason {
378+
if (!stopReason) return false;
379+
return stopReason !== StopReasons.END_TURN;
380+
}
381+
316382
private createMessage(
317383
role: ChatMessage["role"],
318384
content: ContentBlock[] = [],
@@ -415,5 +481,6 @@ export class ACPSession {
415481
this.agentTextBuffer = "";
416482
this.thoughtTextBuffer = "";
417483
this.userTextBuffer = "";
484+
this.turnStops.length = 0;
418485
}
419486
}

0 commit comments

Comments
 (0)