-
Notifications
You must be signed in to change notification settings - Fork 101
Expand file tree
/
Copy pathmessageUtils.ts
More file actions
280 lines (247 loc) · 8.3 KB
/
messageUtils.ts
File metadata and controls
280 lines (247 loc) · 8.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
import type { DisplayedMessage } from "@/common/types/message";
import { formatReviewForModel } from "@/common/types/review";
import type { BashOutputToolArgs } from "@/common/types/tools";
import {
getLastNonDecorativeMessage,
hasExecutingAskUserQuestionInLatestTurn,
} from "@/common/utils/messages/retryEligibility";
/**
* Returns the text that should be placed into the ChatInput when editing a user message.
*/
export function getEditableUserMessageText(
message: Extract<DisplayedMessage, { type: "user" }>
): string {
const reviews = message.reviews;
if (!reviews || reviews.length === 0) {
return message.content;
}
// Reviews are already stored in metadata; strip their rendered tags to avoid duplication on edit.
const reviewText = reviews.map(formatReviewForModel).join("\n\n");
if (!message.content.startsWith(reviewText)) {
return message.content;
}
const remainder = message.content.slice(reviewText.length);
if (remainder.startsWith("\n\n")) {
return remainder.slice(2);
}
if (remainder.startsWith("\n")) {
return remainder.slice(1);
}
return remainder;
}
/**
* Type guard to check if a message is a bash_output tool call with valid args
*/
export function isBashOutputTool(
msg: DisplayedMessage
): msg is DisplayedMessage & { type: "tool"; toolName: "bash_output"; args: BashOutputToolArgs } {
if (msg.type !== "tool" || msg.toolName !== "bash_output") {
return false;
}
// Validate args has required process_id field
const args = msg.args;
return (
typeof args === "object" &&
args !== null &&
"process_id" in args &&
typeof (args as { process_id: unknown }).process_id === "string"
);
}
/**
* Information about a bash_output message's position in a consecutive group.
* Used at render-time to determine how to display the message.
*/
export interface BashOutputGroupInfo {
/** Position in the group: 'first', 'last', or 'middle' (collapsed) */
position: "first" | "last" | "middle";
/** Total number of calls in this group */
totalCount: number;
/** Number of collapsed (hidden) calls between first and last */
collapsedCount: number;
/** Process ID for the collapsed indicator */
processId: string;
/** Index of the first message in this group (used as expand/collapse key) */
firstIndex: number;
}
/**
* Determines if the interrupted barrier should be shown for a DisplayedMessage.
*
* The barrier should show when:
* - Message was interrupted (isPartial) AND not currently streaming
* - For multi-part messages, only show on the last part
*/
export function shouldShowInterruptedBarrier(
msg: DisplayedMessage,
allMessages: DisplayedMessage[] = [msg]
): boolean {
if (
msg.type === "user" ||
msg.type === "stream-error" ||
msg.type === "compaction-boundary" ||
msg.type === "history-hidden" ||
msg.type === "workspace-init" ||
msg.type === "plan-display"
)
return false;
// ask_user_question is intentionally a "waiting for input" state. Even if the
// underlying message is a persisted partial (e.g. after app restart), we keep
// the full latest turn answerable instead of showing "Interrupted" on any
// trailing parts from that same assistant message.
if (hasExecutingAskUserQuestionInLatestTurn(allMessages)) {
const lastMessage = getLastNonDecorativeMessage(allMessages);
if (lastMessage && "historyId" in msg && "historyId" in lastMessage) {
if (msg.historyId === lastMessage.historyId) {
return false;
}
}
}
// Only show on the last part of multi-part messages
if (!msg.isLastPartOfMessage) return false;
// Show if interrupted and not actively streaming (tools don't have isStreaming property)
const isStreaming = "isStreaming" in msg ? msg.isStreaming : false;
return msg.isPartial && !isStreaming;
}
/**
* Returns whether ChatPane should bypass useDeferredValue and render the immediate
* message list. We bypass deferral while assistant content is streaming OR while
* any tool call is still executing (e.g. live bash output).
*
* We also bypass when the deferred snapshot appears stale (it still has active
* streaming/executing rows after the immediate snapshot is idle), or when both
* snapshots have diverged in row identity/order. Showing stale deferred rows can
* cause hidden-marker placement and tool-state flash at stream completion.
*/
export function shouldBypassDeferredMessages(
messages: DisplayedMessage[],
deferredMessages: DisplayedMessage[]
): boolean {
const hasActiveRows = (rows: DisplayedMessage[]) =>
rows.some(
(m) =>
("isStreaming" in m && m.isStreaming) || (m.type === "tool" && m.status === "executing")
);
if (messages.length !== deferredMessages.length) {
return true;
}
for (let i = 0; i < messages.length; i++) {
const immediateMessage = messages[i];
const deferredMessage = deferredMessages[i];
if (!immediateMessage || !deferredMessage) {
return true;
}
if (
immediateMessage.id !== deferredMessage.id ||
immediateMessage.type !== deferredMessage.type
) {
return true;
}
}
return hasActiveRows(messages) || hasActiveRows(deferredMessages);
}
/**
* Merges consecutive stream-error messages with identical content.
* Returns a new array where consecutive identical errors are represented as a single message
* with an errorCount field indicating how many times it occurred.
*
* @param messages - Array of DisplayedMessages to process
* @returns Array with consecutive identical errors merged (errorCount added to stream-error variants)
*/
export function mergeConsecutiveStreamErrors(messages: DisplayedMessage[]): DisplayedMessage[] {
if (messages.length === 0) return [];
const result: DisplayedMessage[] = [];
let i = 0;
while (i < messages.length) {
const msg = messages[i];
// If it's not a stream-error, just add it and move on
if (msg.type !== "stream-error") {
result.push(msg);
i++;
continue;
}
// Count consecutive identical errors
let count = 1;
let j = i + 1;
while (j < messages.length) {
const nextMsg = messages[j];
if (
nextMsg.type === "stream-error" &&
nextMsg.error === msg.error &&
nextMsg.errorType === msg.errorType
) {
count++;
j++;
} else {
break;
}
}
// Add the error with count
result.push({
...msg,
errorCount: count,
});
// Skip all the merged errors
i = j;
}
return result;
}
/**
* Precompute bash_output grouping metadata for all rows in one linear pass.
*
* Why: workspace-open now renders full history in a single commit. Doing a backward+forward
* scan per row turns long transcripts into O(n²) grouping work right on the critical path.
*/
export function computeBashOutputGroupInfos(
messages: DisplayedMessage[]
): Array<BashOutputGroupInfo | undefined> {
const groupInfos = new Array<BashOutputGroupInfo | undefined>(messages.length);
let index = 0;
while (index < messages.length) {
const msg = messages[index];
if (!isBashOutputTool(msg)) {
index++;
continue;
}
const processId = msg.args.process_id;
let groupEnd = index;
while (groupEnd < messages.length - 1) {
const nextMsg = messages[groupEnd + 1];
if (!isBashOutputTool(nextMsg) || nextMsg.args.process_id !== processId) {
break;
}
groupEnd++;
}
const groupSize = groupEnd - index + 1;
if (groupSize >= 3) {
const collapsedCount = groupSize - 2;
for (let groupIndex = index; groupIndex <= groupEnd; groupIndex++) {
let position: BashOutputGroupInfo["position"] = "middle";
if (groupIndex === index) {
position = "first";
} else if (groupIndex === groupEnd) {
position = "last";
}
groupInfos[groupIndex] = {
position,
totalCount: groupSize,
collapsedCount,
processId,
firstIndex: index,
};
}
}
index = groupEnd + 1;
}
return groupInfos;
}
/**
* Computes the bash_output group info for a message at a given index.
*/
export function computeBashOutputGroupInfo(
messages: DisplayedMessage[],
index: number
): BashOutputGroupInfo | undefined {
if (index < 0 || index >= messages.length) {
return undefined;
}
return computeBashOutputGroupInfos(messages)[index];
}