@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.
@@ -0,0 +1,241 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ asAppliedCard,
4
+ asConfirmCard,
5
+ chunkToEvent,
6
+ parseSseBuffer,
7
+ readChatStream,
8
+ type ChatStreamEvent,
9
+ } from "./stream-parser";
10
+
11
+ describe("asConfirmCard", () => {
12
+ test("recognises a well-formed confirm card", () => {
13
+ const card = asConfirmCard({
14
+ __confirm: true,
15
+ toolName: "automation.propose",
16
+ effect: "mutate",
17
+ summary: "Create automation X",
18
+ token: "propose:abc.def",
19
+ payload: { name: "X" },
20
+ expiresAt: "2026-06-01T00:10:00Z",
21
+ });
22
+ expect(card?.toolName).toBe("automation.propose");
23
+ expect(card?.effect).toBe("mutate");
24
+ expect(card?.token).toBe("propose:abc.def");
25
+ });
26
+
27
+ test("rejects a non-card object", () => {
28
+ expect(asConfirmCard({ rows: [] })).toBeUndefined();
29
+ expect(asConfirmCard({ __confirm: true, toolName: 1 })).toBeUndefined();
30
+ expect(asConfirmCard(null)).toBeUndefined();
31
+ expect(asConfirmCard("text")).toBeUndefined();
32
+ });
33
+
34
+ test("parses an update card's before -> after diff", () => {
35
+ const card = asConfirmCard({
36
+ __confirm: true,
37
+ toolName: "healthcheck.update",
38
+ effect: "mutate",
39
+ summary: "Update X",
40
+ token: "propose:1.2",
41
+ payload: {},
42
+ diff: [{ path: "intervalSeconds", before: 60, after: 30 }],
43
+ expiresAt: "2026-06-01T00:10:00Z",
44
+ });
45
+ expect(card?.diff).toEqual([
46
+ { path: "intervalSeconds", before: 60, after: 30 },
47
+ ]);
48
+ });
49
+ });
50
+
51
+ describe("asAppliedCard", () => {
52
+ test("recognises an auto-applied result with its diff", () => {
53
+ const card = asAppliedCard({
54
+ __applied: true,
55
+ toolName: "healthcheck.update",
56
+ summary: "Updated X",
57
+ result: { id: "hc1" },
58
+ diff: [{ path: "intervalSeconds", before: 60, after: 30 }],
59
+ });
60
+ expect(card?.toolName).toBe("healthcheck.update");
61
+ expect(card?.diff).toHaveLength(1);
62
+ expect(card?.result).toEqual({ id: "hc1" });
63
+ });
64
+
65
+ test("rejects a non-applied object", () => {
66
+ expect(asAppliedCard({ __confirm: true })).toBeUndefined();
67
+ expect(asAppliedCard({ __applied: true, toolName: 1 })).toBeUndefined();
68
+ expect(asAppliedCard(null)).toBeUndefined();
69
+ });
70
+ });
71
+
72
+ describe("chunkToEvent", () => {
73
+ test("maps text-delta and text chunks", () => {
74
+ expect(chunkToEvent({ type: "text-delta", delta: "Hi" })).toEqual({
75
+ type: "text-delta",
76
+ delta: "Hi",
77
+ });
78
+ expect(chunkToEvent({ type: "text", text: "Yo" })).toEqual({
79
+ type: "text-delta",
80
+ delta: "Yo",
81
+ });
82
+ });
83
+
84
+ test("maps a tool-input-available chunk to a tool-call event", () => {
85
+ expect(
86
+ chunkToEvent({
87
+ type: "tool-input-available",
88
+ toolCallId: "c1",
89
+ toolName: "incident.list",
90
+ }),
91
+ ).toEqual({ type: "tool-call", toolCallId: "c1", toolName: "incident.list" });
92
+ });
93
+
94
+ test("maps a tool-input-start chunk to a tool-call event", () => {
95
+ expect(
96
+ chunkToEvent({
97
+ type: "tool-input-start",
98
+ toolCallId: "c2",
99
+ toolName: "listCapabilities",
100
+ }),
101
+ ).toEqual({ type: "tool-call", toolCallId: "c2", toolName: "listCapabilities" });
102
+ });
103
+
104
+ test("maps a tool output carrying a confirm card to a confirm-card event", () => {
105
+ const event = chunkToEvent({
106
+ type: "tool-output-available",
107
+ toolCallId: "c3",
108
+ output: {
109
+ __confirm: true,
110
+ toolName: "automation.propose",
111
+ effect: "mutate",
112
+ summary: "do it",
113
+ token: "propose:1.2",
114
+ payload: {},
115
+ expiresAt: "2026-06-01T00:10:00Z",
116
+ },
117
+ });
118
+ expect(event).toMatchObject({ type: "confirm-card", toolCallId: "c3" });
119
+ });
120
+
121
+ test("a read tool output (no card) yields a tool-result event", () => {
122
+ expect(
123
+ chunkToEvent({
124
+ type: "tool-output-available",
125
+ toolCallId: "c4",
126
+ output: { rows: [] },
127
+ }),
128
+ ).toEqual({ type: "tool-result", toolCallId: "c4" });
129
+ });
130
+
131
+ test("maps a tool output carrying an auto-applied result to an applied-card event", () => {
132
+ const event = chunkToEvent({
133
+ type: "tool-output-available",
134
+ toolCallId: "c7",
135
+ output: {
136
+ __applied: true,
137
+ toolName: "automation.update",
138
+ summary: "Updated",
139
+ result: { id: "a1" },
140
+ },
141
+ });
142
+ expect(event).toMatchObject({ type: "applied-card", toolCallId: "c7" });
143
+ });
144
+
145
+ test("maps a tool-output-error chunk to a tool-error event", () => {
146
+ expect(
147
+ chunkToEvent({
148
+ type: "tool-output-error",
149
+ toolCallId: "c5",
150
+ errorText: "Not allowed to read the automation capability catalog",
151
+ }),
152
+ ).toEqual({
153
+ type: "tool-error",
154
+ toolCallId: "c5",
155
+ message: "Not allowed to read the automation capability catalog",
156
+ });
157
+ });
158
+
159
+ test("maps a tool-input-error chunk to a tool-error event with the tool name", () => {
160
+ expect(
161
+ chunkToEvent({
162
+ type: "tool-input-error",
163
+ toolCallId: "c6",
164
+ toolName: "getCapabilitySchema",
165
+ errorText: "invalid input",
166
+ }),
167
+ ).toEqual({
168
+ type: "tool-error",
169
+ toolCallId: "c6",
170
+ toolName: "getCapabilitySchema",
171
+ message: "invalid input",
172
+ });
173
+ });
174
+
175
+ test("maps an error chunk", () => {
176
+ expect(chunkToEvent({ type: "error", errorText: "boom" })).toEqual({
177
+ type: "error",
178
+ message: "boom",
179
+ });
180
+ });
181
+
182
+ test("ignores unknown chunk types", () => {
183
+ expect(chunkToEvent({ type: "start" })).toBeUndefined();
184
+ expect(chunkToEvent(42)).toBeUndefined();
185
+ });
186
+ });
187
+
188
+ describe("parseSseBuffer", () => {
189
+ test("extracts complete data payloads and keeps the partial remainder", () => {
190
+ const buf =
191
+ 'data: {"type":"text-delta","delta":"a"}\n\n' +
192
+ 'data: {"type":"text-delta","delta":"b"}\n\n' +
193
+ 'data: {"type":"text-delta","del'; // incomplete trailing event
194
+ const { payloads, rest } = parseSseBuffer(buf);
195
+ expect(payloads).toHaveLength(2);
196
+ expect(rest).toContain('"del');
197
+ });
198
+
199
+ test("surfaces the [DONE] sentinel", () => {
200
+ const { payloads } = parseSseBuffer("data: [DONE]\n\n");
201
+ expect(payloads).toEqual(["[DONE]"]);
202
+ });
203
+ });
204
+
205
+ /** Build a ReadableStream of UTF-8 bytes from string chunks. */
206
+ function streamOf(chunks: string[]): ReadableStream<Uint8Array> {
207
+ const encoder = new TextEncoder();
208
+ let i = 0;
209
+ return new ReadableStream<Uint8Array>({
210
+ pull(controller) {
211
+ if (i < chunks.length) {
212
+ controller.enqueue(encoder.encode(chunks[i++]));
213
+ } else {
214
+ controller.close();
215
+ }
216
+ },
217
+ });
218
+ }
219
+
220
+ describe("readChatStream (DOM-free)", () => {
221
+ test("yields text deltas, a confirm card, then done across chunk boundaries", async () => {
222
+ const stream = streamOf([
223
+ 'data: {"type":"text-delta","delta":"Hello "}\n\n',
224
+ 'data: {"type":"text-de', // split mid-event
225
+ 'lta","delta":"world"}\n\n',
226
+ 'data: {"type":"tool-output-available","output":{"__confirm":true,"toolName":"automation.propose","effect":"mutate","summary":"s","token":"propose:1.2","payload":{},"expiresAt":"2026-06-01T00:10:00Z"}}\n\n',
227
+ "data: [DONE]\n\n",
228
+ ]);
229
+
230
+ const events: ChatStreamEvent[] = [];
231
+ for await (const e of readChatStream(stream)) events.push(e);
232
+
233
+ const text = events
234
+ .filter((e): e is { type: "text-delta"; delta: string } => e.type === "text-delta")
235
+ .map((e) => e.delta)
236
+ .join("");
237
+ expect(text).toBe("Hello world");
238
+ expect(events.some((e) => e.type === "confirm-card")).toBe(true);
239
+ expect(events.at(-1)?.type).toBe("done");
240
+ });
241
+ });
@@ -0,0 +1,286 @@
1
+ /**
2
+ * DOM-free parser for the AI-SDK UI message SSE stream (Phase 4 chat).
3
+ *
4
+ * The backend streams the Vercel-AI-SDK UI message stream as Server-Sent
5
+ * Events: lines of `data: <json>` separated by blank lines. This module turns a
6
+ * byte stream into incremental, typed chat events WITHOUT touching the DOM, so
7
+ * the streaming logic is unit-testable under `bun test` (which CI runs from the
8
+ * repo root WITHOUT happy-dom). React components consume these events; they are
9
+ * never tested via render.
10
+ */
11
+
12
+ /** One changed field in a before -> after diff (mirrors ai-common AiFieldDiff). */
13
+ export interface FieldDiff {
14
+ path: string;
15
+ before: unknown;
16
+ after: unknown;
17
+ }
18
+
19
+ /** A confirm card emitted when a mutate/destructive tool was proposed. */
20
+ export interface ConfirmCard {
21
+ toolName: string;
22
+ effect: "mutate" | "destructive";
23
+ summary: string;
24
+ token: string;
25
+ payload: unknown;
26
+ /** Optional before -> after diff for an update proposal. */
27
+ diff?: FieldDiff[];
28
+ expiresAt: string;
29
+ }
30
+
31
+ /**
32
+ * An "applied" card emitted when a mutate tool AUTO-APPLIED (auto mode): the
33
+ * change already took effect, so it is read-only. Surfaced so the operator
34
+ * ALWAYS sees what changed/created, even when no confirmation was required.
35
+ */
36
+ export interface AppliedCard {
37
+ toolName: string;
38
+ summary: string;
39
+ /** Optional before -> after diff for an auto-applied update. */
40
+ diff?: FieldDiff[];
41
+ /** The applied result (e.g. the created/updated object), for display. */
42
+ result: unknown;
43
+ }
44
+
45
+ /**
46
+ * Incremental events surfaced to the chat UI. Tool events carry the SDK's
47
+ * `toolCallId` so the reducer can correlate a tool call with its later result /
48
+ * error / confirm-card chunk and render them as one ordered, in-place part.
49
+ */
50
+ export type ChatStreamEvent =
51
+ | { type: "text-delta"; delta: string }
52
+ | { type: "tool-call"; toolCallId: string; toolName: string }
53
+ | { type: "tool-result"; toolCallId: string }
54
+ | { type: "tool-error"; toolCallId: string; toolName?: string; message: string }
55
+ | { type: "confirm-card"; toolCallId: string; card: ConfirmCard }
56
+ | { type: "applied-card"; toolCallId: string; card: AppliedCard }
57
+ | { type: "error"; message: string }
58
+ | { type: "done" };
59
+
60
+ /** True when the value is a record (object), narrowing `unknown` safely. */
61
+ function isRecord(value: unknown): value is Record<string, unknown> {
62
+ return typeof value === "object" && value !== null;
63
+ }
64
+
65
+ /** Narrow a parsed JSON value into a FieldDiff[] if it has the shape. */
66
+ function asFieldDiff(value: unknown): FieldDiff[] | undefined {
67
+ if (!Array.isArray(value)) return undefined;
68
+ const diffs: FieldDiff[] = [];
69
+ for (const entry of value) {
70
+ if (isRecord(entry) && typeof entry.path === "string") {
71
+ diffs.push({ path: entry.path, before: entry.before, after: entry.after });
72
+ }
73
+ }
74
+ return diffs.length > 0 ? diffs : undefined;
75
+ }
76
+
77
+ /** Narrow a parsed JSON object into a ConfirmCard if it has the shape. */
78
+ export function asConfirmCard(value: unknown): ConfirmCard | undefined {
79
+ if (!isRecord(value)) return undefined;
80
+ if (value.__confirm !== true) return undefined;
81
+ const { toolName, effect, summary, token, payload, expiresAt } = value;
82
+ if (
83
+ typeof toolName !== "string" ||
84
+ (effect !== "mutate" && effect !== "destructive") ||
85
+ typeof summary !== "string" ||
86
+ typeof token !== "string" ||
87
+ typeof expiresAt !== "string"
88
+ ) {
89
+ return undefined;
90
+ }
91
+ return {
92
+ toolName,
93
+ effect,
94
+ summary,
95
+ token,
96
+ payload,
97
+ diff: asFieldDiff(value.diff),
98
+ expiresAt,
99
+ };
100
+ }
101
+
102
+ /** Narrow a parsed JSON object into an AppliedCard (auto-applied result). */
103
+ export function asAppliedCard(value: unknown): AppliedCard | undefined {
104
+ if (!isRecord(value)) return undefined;
105
+ if (value.__applied !== true) return undefined;
106
+ const { toolName, summary, result } = value;
107
+ if (typeof toolName !== "string" || typeof summary !== "string") {
108
+ return undefined;
109
+ }
110
+ return { toolName, summary, result, diff: asFieldDiff(value.diff) };
111
+ }
112
+
113
+ /** Read a chunk's `toolCallId` (empty string when absent — defensive). */
114
+ function asToolCallId(chunk: Record<string, unknown>): string {
115
+ return typeof chunk.toolCallId === "string" ? chunk.toolCallId : "";
116
+ }
117
+
118
+ /** Read a chunk's `errorText`, falling back to a generic message. */
119
+ function asErrorText(chunk: Record<string, unknown>, fallback: string): string {
120
+ return typeof chunk.errorText === "string" ? chunk.errorText : fallback;
121
+ }
122
+
123
+ /**
124
+ * Map a single decoded AI-SDK UI-message-stream chunk to a chat event.
125
+ *
126
+ * The AI SDK chunk shapes used here (v6 UI message stream):
127
+ * - `{ type: "text-delta", delta }` / `{ type: "text", text }` -> text
128
+ * - `{ type: "tool-input-start" | "tool-input-available", toolCallId, toolName }`
129
+ * -> a tool was called (de-duplicated by id in the reducer).
130
+ * - `{ type: "tool-output-available", toolCallId, output }` -> a tool result; a
131
+ * confirm-card return value is detected via `asConfirmCard`, otherwise it is a
132
+ * plain read result that just marks the tool call done.
133
+ * - `{ type: "tool-output-error", toolCallId, errorText }` -> a tool's `execute`
134
+ * THREW. Without this branch a failing read tool (e.g. `listCapabilities`)
135
+ * produced a silent EMPTY bubble: the SDK never routes a tool error through
136
+ * the stream's `onError` (that is for stream/provider failures), so it would
137
+ * be dropped entirely.
138
+ * - `{ type: "tool-input-error", toolCallId, toolName, errorText }` -> the model
139
+ * produced invalid tool arguments; surfaced the same way.
140
+ * - `{ type: "error", errorText }` -> a stream/provider error.
141
+ * Unknown chunk types yield `undefined` (ignored).
142
+ */
143
+ export function chunkToEvent(chunk: unknown): ChatStreamEvent | undefined {
144
+ if (!isRecord(chunk)) return undefined;
145
+ const type = chunk.type;
146
+
147
+ if (type === "text-delta" && typeof chunk.delta === "string") {
148
+ return { type: "text-delta", delta: chunk.delta };
149
+ }
150
+ if (type === "text" && typeof chunk.text === "string") {
151
+ return { type: "text-delta", delta: chunk.text };
152
+ }
153
+ if (
154
+ (type === "tool-input-available" ||
155
+ type === "tool-input-start" ||
156
+ type === "tool-call") &&
157
+ typeof chunk.toolName === "string"
158
+ ) {
159
+ return {
160
+ type: "tool-call",
161
+ toolCallId: asToolCallId(chunk),
162
+ toolName: chunk.toolName,
163
+ };
164
+ }
165
+ // The model produced invalid tool arguments (input never became available).
166
+ if (type === "tool-input-error") {
167
+ return {
168
+ type: "tool-error",
169
+ toolCallId: asToolCallId(chunk),
170
+ toolName: typeof chunk.toolName === "string" ? chunk.toolName : undefined,
171
+ message: asErrorText(chunk, "Invalid tool input"),
172
+ };
173
+ }
174
+ // Tool result chunks: a confirm card, otherwise a plain read result.
175
+ if (
176
+ type === "tool-output-available" ||
177
+ type === "tool-result" ||
178
+ type === "tool-output"
179
+ ) {
180
+ const output =
181
+ "output" in chunk ? chunk.output : "result" in chunk ? chunk.result : undefined;
182
+ const card = asConfirmCard(output);
183
+ if (card) {
184
+ return { type: "confirm-card", toolCallId: asToolCallId(chunk), card };
185
+ }
186
+ const applied = asAppliedCard(output);
187
+ if (applied) {
188
+ return { type: "applied-card", toolCallId: asToolCallId(chunk), card: applied };
189
+ }
190
+ return { type: "tool-result", toolCallId: asToolCallId(chunk) };
191
+ }
192
+ // A tool's `execute` threw (e.g. an authz/read failure inside the tool).
193
+ if (type === "tool-output-error") {
194
+ return {
195
+ type: "tool-error",
196
+ toolCallId: asToolCallId(chunk),
197
+ message: asErrorText(chunk, "Tool failed"),
198
+ };
199
+ }
200
+ if (type === "error") {
201
+ const message =
202
+ typeof chunk.errorText === "string"
203
+ ? chunk.errorText
204
+ : typeof chunk.error === "string"
205
+ ? chunk.error
206
+ : "Stream error";
207
+ return { type: "error", message };
208
+ }
209
+ return undefined;
210
+ }
211
+
212
+ /**
213
+ * Split a buffer of SSE text into complete `data:` JSON payloads, returning the
214
+ * parsed payloads plus the unconsumed remainder (an incomplete trailing event).
215
+ * SSE events are separated by a blank line; each event has one or more
216
+ * `data: ...` lines. The `[DONE]` sentinel is surfaced as `done`.
217
+ */
218
+ export function parseSseBuffer(buffer: string): {
219
+ payloads: Array<unknown | "[DONE]">;
220
+ rest: string;
221
+ } {
222
+ const payloads: Array<unknown | "[DONE]"> = [];
223
+ // Events end at a blank line (\n\n). Keep the trailing partial event in `rest`.
224
+ const parts = buffer.split("\n\n");
225
+ const rest = parts.pop() ?? "";
226
+ for (const event of parts) {
227
+ for (const line of event.split("\n")) {
228
+ const trimmed = line.trimStart();
229
+ if (!trimmed.startsWith("data:")) continue;
230
+ const data = trimmed.slice("data:".length).trim();
231
+ if (data === "[DONE]") {
232
+ payloads.push("[DONE]");
233
+ continue;
234
+ }
235
+ if (!data) continue;
236
+ try {
237
+ payloads.push(JSON.parse(data));
238
+ } catch {
239
+ // Ignore malformed JSON lines (defensive).
240
+ }
241
+ }
242
+ }
243
+ return { payloads, rest };
244
+ }
245
+
246
+ /**
247
+ * Consume a ReadableStream of SSE bytes, yielding {@link ChatStreamEvent}s. Pure
248
+ * w.r.t. the DOM — it only needs a `ReadableStream<Uint8Array>` (as returned by
249
+ * `fetch().body`) and `TextDecoder`, both available in node/bun and the browser.
250
+ */
251
+ export async function* readChatStream(
252
+ stream: ReadableStream<Uint8Array>,
253
+ ): AsyncGenerator<ChatStreamEvent> {
254
+ const reader = stream.getReader();
255
+ const decoder = new TextDecoder();
256
+ let buffer = "";
257
+ try {
258
+ for (;;) {
259
+ const { value, done } = await reader.read();
260
+ if (done) break;
261
+ buffer += decoder.decode(value, { stream: true });
262
+ const { payloads, rest } = parseSseBuffer(buffer);
263
+ buffer = rest;
264
+ for (const payload of payloads) {
265
+ if (payload === "[DONE]") {
266
+ yield { type: "done" };
267
+ continue;
268
+ }
269
+ const event = chunkToEvent(payload);
270
+ if (event) yield event;
271
+ }
272
+ }
273
+ // Flush any trailing complete event left in the buffer.
274
+ const { payloads } = parseSseBuffer(buffer + "\n\n");
275
+ for (const payload of payloads) {
276
+ if (payload === "[DONE]") {
277
+ yield { type: "done" };
278
+ continue;
279
+ }
280
+ const event = chunkToEvent(payload);
281
+ if (event) yield event;
282
+ }
283
+ } finally {
284
+ reader.releaseLock();
285
+ }
286
+ }
@@ -0,0 +1,163 @@
1
+ import { useCallback, useRef, useState } from "react";
2
+ import { useApi, fetchApiRef } from "@checkstack/frontend-api";
3
+ import { pluginMetadata } from "@checkstack/ai-common";
4
+ import { extractErrorMessage } from "@checkstack/common";
5
+ import {
6
+ appendUserMessage,
7
+ startAssistantMessage,
8
+ applyStreamEvent,
9
+ finishAssistantMessage,
10
+ type ChatMessage,
11
+ } from "./chat-state";
12
+ import { readChatStream } from "./stream-parser";
13
+
14
+ /** Generate a client-side message id (crypto.randomUUID is widely available). */
15
+ function newId(): string {
16
+ return typeof crypto !== "undefined" && "randomUUID" in crypto
17
+ ? crypto.randomUUID()
18
+ : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
19
+ }
20
+
21
+ /**
22
+ * React hook that drives ONE streaming chat turn against /api/ai/chat. The
23
+ * heavy lifting (SSE parsing, state folding) lives in DOM-free, unit-tested
24
+ * modules (`stream-parser`, `chat-state`); this hook only wires them to React
25
+ * state and the platform `fetch` (which carries auth + base URL). Credentials
26
+ * never reach the browser — the response is a token/tool-event stream only.
27
+ */
28
+ export function useChatTurn({
29
+ initialMessages = [],
30
+ }: {
31
+ initialMessages?: ChatMessage[];
32
+ } = {}) {
33
+ const fetchApi = useApi(fetchApiRef);
34
+ const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
35
+ const [streaming, setStreaming] = useState(false);
36
+ const [error, setError] = useState<string | undefined>();
37
+ const aborter = useRef<AbortController | undefined>();
38
+
39
+ // Shared streaming driver: POST `body` to /chat, start a fresh assistant
40
+ // message (optionally appending a user message first), and fold the SSE
41
+ // events into it. Used by both `send` (a user turn) and `sendDecision` (a
42
+ // confirm-card acknowledgment, which has no user bubble).
43
+ const runStream = useCallback(
44
+ async ({
45
+ body,
46
+ userText,
47
+ }: {
48
+ body: Record<string, unknown>;
49
+ userText?: string;
50
+ }) => {
51
+ setError(undefined);
52
+ setStreaming(true);
53
+ setMessages((prev) => {
54
+ const base =
55
+ userText === undefined
56
+ ? prev
57
+ : appendUserMessage({ messages: prev, id: newId(), text: userText });
58
+ return startAssistantMessage({ messages: base, id: newId() });
59
+ });
60
+ const controller = new AbortController();
61
+ aborter.current = controller;
62
+ try {
63
+ const response = await fetchApi
64
+ .forPlugin(pluginMetadata.pluginId)
65
+ .fetch("/chat", {
66
+ method: "POST",
67
+ headers: { "content-type": "application/json" },
68
+ body: JSON.stringify(body),
69
+ signal: controller.signal,
70
+ });
71
+ if (!response.ok || !response.body) {
72
+ const detail = await response.text().catch(() => "");
73
+ throw new Error(detail || `Chat failed (${response.status})`);
74
+ }
75
+ for await (const event of readChatStream(response.body)) {
76
+ // An in-stream error (the server's onError surfaced a provider/stream
77
+ // failure, e.g. a 400 `invalid_prompt`) arrives as a normal stream
78
+ // event, NOT a thrown exception, so the catch below never runs. Lift
79
+ // it into the durable `error` state so a post-turn message refetch
80
+ // (which re-hydrates from the persisted transcript that never captured
81
+ // this failed turn) cannot wipe it from view.
82
+ if (event.type === "error") setError(event.message);
83
+ setMessages((prev) => applyStreamEvent({ messages: prev, event }));
84
+ }
85
+ } catch (error_) {
86
+ const message = extractErrorMessage(error_, "Chat turn failed");
87
+ setError(message);
88
+ setMessages((prev) =>
89
+ applyStreamEvent({ messages: prev, event: { type: "error", message } }),
90
+ );
91
+ } finally {
92
+ setMessages((prev) => finishAssistantMessage({ messages: prev }));
93
+ setStreaming(false);
94
+ aborter.current = undefined;
95
+ }
96
+ },
97
+ [fetchApi],
98
+ );
99
+
100
+ const send = useCallback(
101
+ ({
102
+ conversationId,
103
+ connectionId,
104
+ model,
105
+ text,
106
+ }: {
107
+ conversationId: string;
108
+ connectionId: string;
109
+ model?: string;
110
+ text: string;
111
+ }) =>
112
+ runStream({
113
+ body: { conversationId, connectionId, model, message: text },
114
+ userText: text,
115
+ }),
116
+ [runStream],
117
+ );
118
+
119
+ // Stream the model's acknowledgment after the operator applies/declines a
120
+ // confirm card. The actual apply ran via `applyTool`; this only makes the
121
+ // model react (no user bubble is added).
122
+ const sendDecision = useCallback(
123
+ ({
124
+ conversationId,
125
+ connectionId,
126
+ model,
127
+ token,
128
+ decision,
129
+ }: {
130
+ conversationId: string;
131
+ connectionId: string;
132
+ model?: string;
133
+ token: string;
134
+ decision: "apply" | "decline";
135
+ }) =>
136
+ runStream({
137
+ body: {
138
+ conversationId,
139
+ connectionId,
140
+ model,
141
+ decision: { token, kind: decision },
142
+ },
143
+ }),
144
+ [runStream],
145
+ );
146
+
147
+ const stop = useCallback(() => {
148
+ aborter.current?.abort();
149
+ }, []);
150
+
151
+ const clearError = useCallback(() => setError(undefined), []);
152
+
153
+ return {
154
+ messages,
155
+ setMessages,
156
+ streaming,
157
+ error,
158
+ send,
159
+ sendDecision,
160
+ stop,
161
+ clearError,
162
+ };
163
+ }