@checkstack/ai-frontend 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +75 -0
- package/package.json +32 -0
- package/src/components/AppliedCardView.tsx +36 -0
- package/src/components/ConfirmCardView.tsx +117 -0
- package/src/components/DiffView.tsx +84 -0
- package/src/components/SideBySideDiff.tsx +120 -0
- package/src/index.tsx +22 -0
- package/src/lib/chat-state.test.ts +213 -0
- package/src/lib/chat-state.ts +231 -0
- package/src/lib/line-diff.test.ts +87 -0
- package/src/lib/line-diff.ts +206 -0
- package/src/lib/mode-toggle.logic.test.ts +64 -0
- package/src/lib/mode-toggle.logic.ts +57 -0
- package/src/lib/model-options.logic.test.ts +55 -0
- package/src/lib/model-options.logic.ts +31 -0
- package/src/lib/new-chat.logic.test.ts +84 -0
- package/src/lib/new-chat.logic.ts +62 -0
- package/src/lib/stream-parser.test.ts +241 -0
- package/src/lib/stream-parser.ts +286 -0
- package/src/lib/use-chat-turn.ts +163 -0
- package/src/pages/ChatPage.tsx +661 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
appendUserMessage,
|
|
4
|
+
startAssistantMessage,
|
|
5
|
+
applyStreamEvent,
|
|
6
|
+
finishAssistantMessage,
|
|
7
|
+
type AssistantPart,
|
|
8
|
+
type ChatMessage,
|
|
9
|
+
} from "./chat-state";
|
|
10
|
+
|
|
11
|
+
/** Narrow the assistant message's parts to a given kind for terse assertions. */
|
|
12
|
+
function partsOf(msgs: ChatMessage[]): AssistantPart[] {
|
|
13
|
+
return msgs[0].parts;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("chat-state reducer (DOM-free)", () => {
|
|
17
|
+
test("appendUserMessage adds a non-streaming user message", () => {
|
|
18
|
+
const msgs = appendUserMessage({ messages: [], id: "u1", text: "hi" });
|
|
19
|
+
expect(msgs).toHaveLength(1);
|
|
20
|
+
expect(msgs[0]).toMatchObject({ role: "user", text: "hi", streaming: false });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("text deltas accumulate into a single trailing text part", () => {
|
|
24
|
+
let msgs = startAssistantMessage({ messages: [], id: "a1" });
|
|
25
|
+
msgs = applyStreamEvent({ messages: msgs, event: { type: "text-delta", delta: "Hel" } });
|
|
26
|
+
msgs = applyStreamEvent({ messages: msgs, event: { type: "text-delta", delta: "lo" } });
|
|
27
|
+
expect(partsOf(msgs)).toEqual([{ kind: "text", text: "Hello" }]);
|
|
28
|
+
expect(msgs[0].streaming).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("tool-call events are de-duplicated by call id", () => {
|
|
32
|
+
let msgs = startAssistantMessage({ messages: [], id: "a1" });
|
|
33
|
+
const event = {
|
|
34
|
+
type: "tool-call",
|
|
35
|
+
toolCallId: "c1",
|
|
36
|
+
toolName: "incident.list",
|
|
37
|
+
} as const;
|
|
38
|
+
msgs = applyStreamEvent({ messages: msgs, event });
|
|
39
|
+
msgs = applyStreamEvent({ messages: msgs, event });
|
|
40
|
+
expect(partsOf(msgs)).toEqual([
|
|
41
|
+
{ kind: "tool", toolCallId: "c1", toolName: "incident.list", status: "running" },
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("a tool result marks its tool part done", () => {
|
|
46
|
+
let msgs = startAssistantMessage({ messages: [], id: "a1" });
|
|
47
|
+
msgs = applyStreamEvent({
|
|
48
|
+
messages: msgs,
|
|
49
|
+
event: { type: "tool-call", toolCallId: "c1", toolName: "listCapabilities" },
|
|
50
|
+
});
|
|
51
|
+
msgs = applyStreamEvent({
|
|
52
|
+
messages: msgs,
|
|
53
|
+
event: { type: "tool-result", toolCallId: "c1" },
|
|
54
|
+
});
|
|
55
|
+
expect(partsOf(msgs)).toEqual([
|
|
56
|
+
{ kind: "tool", toolCallId: "c1", toolName: "listCapabilities", status: "done" },
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a tool error marks its tool part errored with the message", () => {
|
|
61
|
+
let msgs = startAssistantMessage({ messages: [], id: "a1" });
|
|
62
|
+
msgs = applyStreamEvent({
|
|
63
|
+
messages: msgs,
|
|
64
|
+
event: { type: "tool-call", toolCallId: "c1", toolName: "listCapabilities" },
|
|
65
|
+
});
|
|
66
|
+
msgs = applyStreamEvent({
|
|
67
|
+
messages: msgs,
|
|
68
|
+
event: { type: "tool-error", toolCallId: "c1", message: "boom" },
|
|
69
|
+
});
|
|
70
|
+
expect(partsOf(msgs)).toEqual([
|
|
71
|
+
{
|
|
72
|
+
kind: "tool",
|
|
73
|
+
toolCallId: "c1",
|
|
74
|
+
toolName: "listCapabilities",
|
|
75
|
+
status: "error",
|
|
76
|
+
errorText: "boom",
|
|
77
|
+
},
|
|
78
|
+
]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("a tool error with no prior tool-call still surfaces an errored part", () => {
|
|
82
|
+
let msgs = startAssistantMessage({ messages: [], id: "a1" });
|
|
83
|
+
msgs = applyStreamEvent({
|
|
84
|
+
messages: msgs,
|
|
85
|
+
event: {
|
|
86
|
+
type: "tool-error",
|
|
87
|
+
toolCallId: "c1",
|
|
88
|
+
toolName: "listCapabilities",
|
|
89
|
+
message: "bad input",
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
expect(partsOf(msgs)).toEqual([
|
|
93
|
+
{
|
|
94
|
+
kind: "tool",
|
|
95
|
+
toolCallId: "c1",
|
|
96
|
+
toolName: "listCapabilities",
|
|
97
|
+
status: "error",
|
|
98
|
+
errorText: "bad input",
|
|
99
|
+
},
|
|
100
|
+
]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("text and tool calls interleave in chronological order", () => {
|
|
104
|
+
let msgs = startAssistantMessage({ messages: [], id: "a1" });
|
|
105
|
+
msgs = applyStreamEvent({ messages: msgs, event: { type: "text-delta", delta: "Checking. " } });
|
|
106
|
+
msgs = applyStreamEvent({
|
|
107
|
+
messages: msgs,
|
|
108
|
+
event: { type: "tool-call", toolCallId: "c1", toolName: "listCapabilities" },
|
|
109
|
+
});
|
|
110
|
+
msgs = applyStreamEvent({
|
|
111
|
+
messages: msgs,
|
|
112
|
+
event: { type: "tool-result", toolCallId: "c1" },
|
|
113
|
+
});
|
|
114
|
+
msgs = applyStreamEvent({ messages: msgs, event: { type: "text-delta", delta: "Done." } });
|
|
115
|
+
expect(partsOf(msgs).map((p) => p.kind)).toEqual(["text", "tool", "text"]);
|
|
116
|
+
const [first, , third] = partsOf(msgs);
|
|
117
|
+
expect(first).toEqual({ kind: "text", text: "Checking. " });
|
|
118
|
+
expect(third).toEqual({ kind: "text", text: "Done." });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("a confirm card replaces its proposing tool part in place", () => {
|
|
122
|
+
let msgs = startAssistantMessage({ messages: [], id: "a1" });
|
|
123
|
+
msgs = applyStreamEvent({
|
|
124
|
+
messages: msgs,
|
|
125
|
+
event: { type: "tool-call", toolCallId: "c9", toolName: "automation.propose" },
|
|
126
|
+
});
|
|
127
|
+
msgs = applyStreamEvent({
|
|
128
|
+
messages: msgs,
|
|
129
|
+
event: {
|
|
130
|
+
type: "confirm-card",
|
|
131
|
+
toolCallId: "c9",
|
|
132
|
+
card: {
|
|
133
|
+
toolName: "automation.propose",
|
|
134
|
+
effect: "mutate",
|
|
135
|
+
summary: "s",
|
|
136
|
+
token: "propose:1.2",
|
|
137
|
+
payload: {},
|
|
138
|
+
expiresAt: "2026-06-01T00:10:00Z",
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
expect(partsOf(msgs)).toHaveLength(1);
|
|
143
|
+
const part = partsOf(msgs)[0];
|
|
144
|
+
expect(part.kind).toBe("confirm");
|
|
145
|
+
if (part.kind === "confirm") {
|
|
146
|
+
expect(part.card.token).toBe("propose:1.2");
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("an auto-applied result replaces its tool part with an applied card", () => {
|
|
151
|
+
let msgs = startAssistantMessage({ messages: [], id: "a1" });
|
|
152
|
+
msgs = applyStreamEvent({
|
|
153
|
+
messages: msgs,
|
|
154
|
+
event: { type: "tool-call", toolCallId: "c5", toolName: "healthcheck.update" },
|
|
155
|
+
});
|
|
156
|
+
msgs = applyStreamEvent({
|
|
157
|
+
messages: msgs,
|
|
158
|
+
event: {
|
|
159
|
+
type: "applied-card",
|
|
160
|
+
toolCallId: "c5",
|
|
161
|
+
card: {
|
|
162
|
+
toolName: "healthcheck.update",
|
|
163
|
+
summary: "Updated X",
|
|
164
|
+
result: { id: "hc1" },
|
|
165
|
+
diff: [{ path: "intervalSeconds", before: 60, after: 30 }],
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
expect(partsOf(msgs)).toHaveLength(1);
|
|
170
|
+
const part = partsOf(msgs)[0];
|
|
171
|
+
expect(part.kind).toBe("applied");
|
|
172
|
+
if (part.kind === "applied") {
|
|
173
|
+
expect(part.card.diff).toHaveLength(1);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("done marks the assistant message non-streaming", () => {
|
|
178
|
+
let msgs = startAssistantMessage({ messages: [], id: "a1" });
|
|
179
|
+
msgs = applyStreamEvent({ messages: msgs, event: { type: "done" } });
|
|
180
|
+
expect(msgs[0].streaming).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("an error event appends an error text part and stops streaming", () => {
|
|
184
|
+
let msgs = startAssistantMessage({ messages: [], id: "a1" });
|
|
185
|
+
msgs = applyStreamEvent({ messages: msgs, event: { type: "error", message: "boom" } });
|
|
186
|
+
const part = partsOf(msgs)[0];
|
|
187
|
+
expect(part).toEqual({ kind: "text", text: "Error: boom" });
|
|
188
|
+
expect(msgs[0].streaming).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("events never mutate a trailing user message", () => {
|
|
192
|
+
const base: ChatMessage[] = appendUserMessage({ messages: [], id: "u1", text: "hi" });
|
|
193
|
+
const after = applyStreamEvent({ messages: base, event: { type: "text-delta", delta: "x" } });
|
|
194
|
+
expect(after).toBe(base);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("a no-op event returns the same array reference", () => {
|
|
198
|
+
const msgs = startAssistantMessage({ messages: [], id: "a1" });
|
|
199
|
+
// tool-result for an unknown call id changes nothing.
|
|
200
|
+
const after = applyStreamEvent({
|
|
201
|
+
messages: msgs,
|
|
202
|
+
event: { type: "tool-result", toolCallId: "missing" },
|
|
203
|
+
});
|
|
204
|
+
expect(after).toBe(msgs);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("finishAssistantMessage is idempotent on a finished message", () => {
|
|
208
|
+
let msgs = startAssistantMessage({ messages: [], id: "a1" });
|
|
209
|
+
msgs = finishAssistantMessage({ messages: msgs });
|
|
210
|
+
const again = finishAssistantMessage({ messages: msgs });
|
|
211
|
+
expect(again).toBe(msgs);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import type { ChatStreamEvent, ConfirmCard, AppliedCard } from "./stream-parser";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One ordered piece of an assistant turn. The model interleaves prose and tool
|
|
5
|
+
* calls ("let me check the catalog" -> call `listCapabilities` -> "here's what I
|
|
6
|
+
* found" -> propose), so the turn is an ORDERED list of parts, not a single text
|
|
7
|
+
* blob plus a flat tool list. Rendering parts in order preserves the visual
|
|
8
|
+
* breaks between text segments and shows each tool call (and its result/error)
|
|
9
|
+
* at the point in the turn where it happened.
|
|
10
|
+
*/
|
|
11
|
+
export type AssistantPart =
|
|
12
|
+
| { kind: "text"; text: string }
|
|
13
|
+
| {
|
|
14
|
+
kind: "tool";
|
|
15
|
+
toolCallId: string;
|
|
16
|
+
toolName: string;
|
|
17
|
+
status: "running" | "done" | "error";
|
|
18
|
+
/** Set when `status` is "error": the tool's failure message. */
|
|
19
|
+
errorText?: string;
|
|
20
|
+
}
|
|
21
|
+
| { kind: "confirm"; toolCallId: string; card: ConfirmCard }
|
|
22
|
+
| { kind: "applied"; toolCallId: string; card: AppliedCard };
|
|
23
|
+
|
|
24
|
+
/** A rendered chat message in the UI. */
|
|
25
|
+
export interface ChatMessage {
|
|
26
|
+
id: string;
|
|
27
|
+
role: "user" | "assistant";
|
|
28
|
+
/** User message text (right-aligned bubble). Assistant content is in `parts`. */
|
|
29
|
+
text: string;
|
|
30
|
+
/** Ordered assistant content: interleaved text / tool / confirm-card parts. */
|
|
31
|
+
parts: AssistantPart[];
|
|
32
|
+
/** A streaming assistant message is still being appended to. */
|
|
33
|
+
streaming: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Append a user message. */
|
|
37
|
+
export function appendUserMessage({
|
|
38
|
+
messages,
|
|
39
|
+
id,
|
|
40
|
+
text,
|
|
41
|
+
}: {
|
|
42
|
+
messages: ChatMessage[];
|
|
43
|
+
id: string;
|
|
44
|
+
text: string;
|
|
45
|
+
}): ChatMessage[] {
|
|
46
|
+
return [
|
|
47
|
+
...messages,
|
|
48
|
+
{ id, role: "user", text, parts: [], streaming: false },
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Start a new streaming assistant message. */
|
|
53
|
+
export function startAssistantMessage({
|
|
54
|
+
messages,
|
|
55
|
+
id,
|
|
56
|
+
}: {
|
|
57
|
+
messages: ChatMessage[];
|
|
58
|
+
id: string;
|
|
59
|
+
}): ChatMessage[] {
|
|
60
|
+
return [
|
|
61
|
+
...messages,
|
|
62
|
+
{ id, role: "assistant", text: "", parts: [], streaming: true },
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Append a text delta, merging into the trailing text part when there is one. */
|
|
67
|
+
function appendText(parts: AssistantPart[], delta: string): AssistantPart[] {
|
|
68
|
+
if (!delta) return parts;
|
|
69
|
+
const last = parts.at(-1);
|
|
70
|
+
if (last && last.kind === "text") {
|
|
71
|
+
return [...parts.slice(0, -1), { ...last, text: last.text + delta }];
|
|
72
|
+
}
|
|
73
|
+
return [...parts, { kind: "text", text: delta }];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Add a tool part, de-duplicating repeat chunks for the same call id. */
|
|
77
|
+
function upsertTool(
|
|
78
|
+
parts: AssistantPart[],
|
|
79
|
+
toolCallId: string,
|
|
80
|
+
toolName: string,
|
|
81
|
+
): AssistantPart[] {
|
|
82
|
+
// The SDK emits both `tool-input-start` and `tool-input-available` for one
|
|
83
|
+
// call; both map to a `tool-call` event, so de-dupe by id.
|
|
84
|
+
const exists = parts.some((p) => p.kind !== "text" && p.toolCallId === toolCallId);
|
|
85
|
+
if (exists) return parts;
|
|
86
|
+
return [...parts, { kind: "tool", toolCallId, toolName, status: "running" }];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Mark a tool part done (its result arrived without an error). */
|
|
90
|
+
function markToolDone(
|
|
91
|
+
parts: AssistantPart[],
|
|
92
|
+
toolCallId: string,
|
|
93
|
+
): AssistantPart[] {
|
|
94
|
+
let changed = false;
|
|
95
|
+
const next = parts.map((p) => {
|
|
96
|
+
if (p.kind === "tool" && p.toolCallId === toolCallId && p.status === "running") {
|
|
97
|
+
changed = true;
|
|
98
|
+
return { ...p, status: "done" as const };
|
|
99
|
+
}
|
|
100
|
+
return p;
|
|
101
|
+
});
|
|
102
|
+
return changed ? next : parts;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Mark a tool part errored, or add an errored tool part if none exists yet. */
|
|
106
|
+
function markToolError(
|
|
107
|
+
parts: AssistantPart[],
|
|
108
|
+
toolCallId: string,
|
|
109
|
+
toolName: string | undefined,
|
|
110
|
+
message: string,
|
|
111
|
+
): AssistantPart[] {
|
|
112
|
+
const idx = parts.findIndex(
|
|
113
|
+
(p) => p.kind === "tool" && p.toolCallId === toolCallId,
|
|
114
|
+
);
|
|
115
|
+
if (idx !== -1) {
|
|
116
|
+
const next = [...parts];
|
|
117
|
+
const existing = next[idx];
|
|
118
|
+
if (existing.kind === "tool") {
|
|
119
|
+
next[idx] = { ...existing, status: "error", errorText: message };
|
|
120
|
+
}
|
|
121
|
+
return next;
|
|
122
|
+
}
|
|
123
|
+
// A `tool-input-error` can arrive before any `tool-call` part exists.
|
|
124
|
+
return [
|
|
125
|
+
...parts,
|
|
126
|
+
{
|
|
127
|
+
kind: "tool",
|
|
128
|
+
toolCallId,
|
|
129
|
+
toolName: toolName ?? "tool",
|
|
130
|
+
status: "error",
|
|
131
|
+
errorText: message,
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Replace a tool part with a card (confirm or applied) in place - the mutate
|
|
138
|
+
* tool's output IS the card - preserving position. Pushes a new card part if the
|
|
139
|
+
* matching tool part is missing (defensive against an out-of-order stream).
|
|
140
|
+
*/
|
|
141
|
+
function toCard(
|
|
142
|
+
parts: AssistantPart[],
|
|
143
|
+
toolCallId: string,
|
|
144
|
+
cardPart: AssistantPart,
|
|
145
|
+
): AssistantPart[] {
|
|
146
|
+
const idx = parts.findIndex(
|
|
147
|
+
(p) => p.kind === "tool" && p.toolCallId === toolCallId,
|
|
148
|
+
);
|
|
149
|
+
if (idx !== -1) {
|
|
150
|
+
const next = [...parts];
|
|
151
|
+
next[idx] = cardPart;
|
|
152
|
+
return next;
|
|
153
|
+
}
|
|
154
|
+
return [...parts, cardPart];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Fold a single stream event into the LAST (streaming assistant) message. Pure
|
|
159
|
+
* and DOM-free so the streaming reducer is unit-testable. Returns the same array
|
|
160
|
+
* reference when the event produces no change (so React can bail on re-render).
|
|
161
|
+
*/
|
|
162
|
+
export function applyStreamEvent({
|
|
163
|
+
messages,
|
|
164
|
+
event,
|
|
165
|
+
}: {
|
|
166
|
+
messages: ChatMessage[];
|
|
167
|
+
event: ChatStreamEvent;
|
|
168
|
+
}): ChatMessage[] {
|
|
169
|
+
const last = messages.at(-1);
|
|
170
|
+
if (!last || last.role !== "assistant") return messages;
|
|
171
|
+
|
|
172
|
+
let parts = last.parts;
|
|
173
|
+
let streaming = last.streaming;
|
|
174
|
+
switch (event.type) {
|
|
175
|
+
case "text-delta": {
|
|
176
|
+
parts = appendText(parts, event.delta);
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
case "tool-call": {
|
|
180
|
+
parts = upsertTool(parts, event.toolCallId, event.toolName);
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
case "tool-result": {
|
|
184
|
+
parts = markToolDone(parts, event.toolCallId);
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
case "tool-error": {
|
|
188
|
+
parts = markToolError(parts, event.toolCallId, event.toolName, event.message);
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
case "confirm-card": {
|
|
192
|
+
parts = toCard(parts, event.toolCallId, {
|
|
193
|
+
kind: "confirm",
|
|
194
|
+
toolCallId: event.toolCallId,
|
|
195
|
+
card: event.card,
|
|
196
|
+
});
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
case "applied-card": {
|
|
200
|
+
parts = toCard(parts, event.toolCallId, {
|
|
201
|
+
kind: "applied",
|
|
202
|
+
toolCallId: event.toolCallId,
|
|
203
|
+
card: event.card,
|
|
204
|
+
});
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
case "error": {
|
|
208
|
+
const prefix = parts.length > 0 ? "\n\n" : "";
|
|
209
|
+
parts = appendText(parts, `${prefix}Error: ${event.message}`);
|
|
210
|
+
streaming = false;
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
case "done": {
|
|
214
|
+
streaming = false;
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (parts === last.parts && streaming === last.streaming) return messages;
|
|
219
|
+
return [...messages.slice(0, -1), { ...last, parts, streaming }];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Mark the last assistant message as finished (stream ended). */
|
|
223
|
+
export function finishAssistantMessage({
|
|
224
|
+
messages,
|
|
225
|
+
}: {
|
|
226
|
+
messages: ChatMessage[];
|
|
227
|
+
}): ChatMessage[] {
|
|
228
|
+
const last = messages.at(-1);
|
|
229
|
+
if (!last || last.role !== "assistant" || !last.streaming) return messages;
|
|
230
|
+
return [...messages.slice(0, -1), { ...last, streaming: false }];
|
|
231
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { computeLineDiff, type DiffSegment } from "./line-diff";
|
|
3
|
+
|
|
4
|
+
/** Join a side's segments back into its full line text. */
|
|
5
|
+
function join(segments: DiffSegment[] | null): string | null {
|
|
6
|
+
return segments === null ? null : segments.map((s) => s.text).join("");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("computeLineDiff", () => {
|
|
10
|
+
test("identical text yields all-equal rows with sequential line numbers", () => {
|
|
11
|
+
const rows = computeLineDiff({ before: "a\nb", after: "a\nb" });
|
|
12
|
+
expect(rows.map((r) => r.kind)).toEqual(["equal", "equal"]);
|
|
13
|
+
expect(rows.map((r) => [r.leftNo, r.rightNo])).toEqual([
|
|
14
|
+
[1, 1],
|
|
15
|
+
[2, 2],
|
|
16
|
+
]);
|
|
17
|
+
// No intra-line emphasis on unchanged lines.
|
|
18
|
+
expect(rows.every((r) => (r.left ?? []).every((s) => !s.emphasis))).toBe(
|
|
19
|
+
true,
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("a single-line edit is one changed row with word-level emphasis", () => {
|
|
24
|
+
const rows = computeLineDiff({
|
|
25
|
+
before: "load < 0.6",
|
|
26
|
+
after: "load < threshold",
|
|
27
|
+
});
|
|
28
|
+
expect(rows).toHaveLength(1);
|
|
29
|
+
const row = rows[0];
|
|
30
|
+
expect(row.kind).toBe("changed");
|
|
31
|
+
expect([row.leftNo, row.rightNo]).toEqual([1, 1]);
|
|
32
|
+
// Segments reconstruct each side's full line...
|
|
33
|
+
expect(join(row.left)).toBe("load < 0.6");
|
|
34
|
+
expect(join(row.right)).toBe("load < threshold");
|
|
35
|
+
// ...the shared prefix "load < " is NOT emphasised on either side...
|
|
36
|
+
expect(row.left?.[0]).toEqual({ text: "load < ", emphasis: false });
|
|
37
|
+
expect(row.right?.[0]).toEqual({ text: "load < ", emphasis: false });
|
|
38
|
+
// ...and the diverging tokens ARE emphasised on each side.
|
|
39
|
+
expect(row.left?.some((s) => s.emphasis)).toBe(true);
|
|
40
|
+
expect(row.right?.some((s) => s.emphasis)).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a pure insertion is an added row (no left line/number)", () => {
|
|
44
|
+
const rows = computeLineDiff({ before: "a", after: "a\nb" });
|
|
45
|
+
expect(rows.map((r) => r.kind)).toEqual(["equal", "added"]);
|
|
46
|
+
const added = rows[1];
|
|
47
|
+
expect(added.leftNo).toBeNull();
|
|
48
|
+
expect(added.left).toBeNull();
|
|
49
|
+
expect(added.rightNo).toBe(2);
|
|
50
|
+
expect(join(added.right)).toBe("b");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a pure deletion is a removed row (no right line/number)", () => {
|
|
54
|
+
const rows = computeLineDiff({ before: "a\nb", after: "a" });
|
|
55
|
+
expect(rows.map((r) => r.kind)).toEqual(["equal", "removed"]);
|
|
56
|
+
const removed = rows[1];
|
|
57
|
+
expect(removed.rightNo).toBeNull();
|
|
58
|
+
expect(removed.right).toBeNull();
|
|
59
|
+
expect(removed.leftNo).toBe(2);
|
|
60
|
+
expect(join(removed.left)).toBe("b");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("an edited block zips dels and inss into changed rows, equals preserved", () => {
|
|
64
|
+
const rows = computeLineDiff({
|
|
65
|
+
before: "keep\nold1\nold2",
|
|
66
|
+
after: "keep\nnew1\nnew2",
|
|
67
|
+
});
|
|
68
|
+
expect(rows.map((r) => r.kind)).toEqual(["equal", "changed", "changed"]);
|
|
69
|
+
expect(rows.map((r) => [join(r.left), join(r.right)])).toEqual([
|
|
70
|
+
["keep", "keep"],
|
|
71
|
+
["old1", "new1"],
|
|
72
|
+
["old2", "new2"],
|
|
73
|
+
]);
|
|
74
|
+
expect(rows.map((r) => [r.leftNo, r.rightNo])).toEqual([
|
|
75
|
+
[1, 1],
|
|
76
|
+
[2, 2],
|
|
77
|
+
[3, 3],
|
|
78
|
+
]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("more insertions than deletions leaves trailing added rows", () => {
|
|
82
|
+
const rows = computeLineDiff({ before: "old1", after: "new1\nnew2" });
|
|
83
|
+
expect(rows.map((r) => r.kind)).toEqual(["changed", "added"]);
|
|
84
|
+
expect(rows[1].leftNo).toBeNull();
|
|
85
|
+
expect(join(rows[1].right)).toBe("new2");
|
|
86
|
+
});
|
|
87
|
+
});
|