@iloveagents/foundry-agent 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,186 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { createServiceFetch } from "../client/service-fetch.ts";
3
+
4
+ describe("createServiceFetch", () => {
5
+ let fetchSpy: ReturnType<typeof vi.spyOn>;
6
+
7
+ beforeEach(() => {
8
+ fetchSpy = vi
9
+ .spyOn(globalThis, "fetch")
10
+ .mockResolvedValue(new Response(null, { status: 200 }));
11
+ });
12
+
13
+ afterEach(() => {
14
+ vi.restoreAllMocks();
15
+ });
16
+
17
+ it("attaches Bearer token from acquireToken", async () => {
18
+ const acquireToken = vi.fn().mockResolvedValue("test-token");
19
+ const serviceFetch = createServiceFetch({ acquireToken });
20
+
21
+ await serviceFetch("/api/spaces/entities");
22
+
23
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
24
+ const [url, init] = fetchSpy.mock.calls[0]!;
25
+ expect(url).toBe("/api/spaces/entities");
26
+ const headers = new Headers(init?.headers);
27
+ expect(headers.get("Authorization")).toBe("Bearer test-token");
28
+ });
29
+
30
+ it("skips token attachment when acquireToken returns null", async () => {
31
+ const acquireToken = vi.fn().mockResolvedValue(null);
32
+ const serviceFetch = createServiceFetch({ acquireToken });
33
+
34
+ await serviceFetch("/api/spaces/entities");
35
+
36
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
37
+ const [, init] = fetchSpy.mock.calls[0]!;
38
+ const headers = new Headers(init?.headers);
39
+ expect(headers.get("Authorization")).toBeNull();
40
+ });
41
+
42
+ it("skips token attachment when Authorization header already present", async () => {
43
+ const acquireToken = vi.fn().mockResolvedValue("test-token");
44
+ const serviceFetch = createServiceFetch({ acquireToken });
45
+
46
+ await serviceFetch("/api/spaces/entities", {
47
+ headers: { Authorization: "Bearer pre-existing" },
48
+ });
49
+
50
+ expect(acquireToken).not.toHaveBeenCalled();
51
+ const [, init] = fetchSpy.mock.calls[0]!;
52
+ const headers = new Headers(init?.headers);
53
+ expect(headers.get("Authorization")).toBe("Bearer pre-existing");
54
+ });
55
+
56
+ it("rewrites local-relative URLs to baseUrl when configured", async () => {
57
+ const acquireToken = vi.fn().mockResolvedValue(null);
58
+ const serviceFetch = createServiceFetch({
59
+ acquireToken,
60
+ baseUrl: "https://api.example.com",
61
+ });
62
+
63
+ await serviceFetch("/api/spaces/entities");
64
+
65
+ const [url] = fetchSpy.mock.calls[0]!;
66
+ expect(url).toBe("https://api.example.com/api/spaces/entities");
67
+ });
68
+
69
+ it("strips origin prefix before applying baseUrl", async () => {
70
+ const acquireToken = vi.fn().mockResolvedValue(null);
71
+ const serviceFetch = createServiceFetch({
72
+ acquireToken,
73
+ baseUrl: "https://api.example.com",
74
+ originResolver: () => "https://app.example.com",
75
+ });
76
+
77
+ await serviceFetch("https://app.example.com/api/spaces/entities");
78
+
79
+ const [url] = fetchSpy.mock.calls[0]!;
80
+ expect(url).toBe("https://api.example.com/api/spaces/entities");
81
+ });
82
+
83
+ it("leaves URL untouched when baseUrl is empty (dev mode)", async () => {
84
+ const acquireToken = vi.fn().mockResolvedValue(null);
85
+ const serviceFetch = createServiceFetch({ acquireToken });
86
+
87
+ await serviceFetch("/api/spaces/entities");
88
+
89
+ const [url] = fetchSpy.mock.calls[0]!;
90
+ expect(url).toBe("/api/spaces/entities");
91
+ });
92
+
93
+ it("strips trailing slashes from baseUrl", async () => {
94
+ const acquireToken = vi.fn().mockResolvedValue(null);
95
+ const serviceFetch = createServiceFetch({
96
+ acquireToken,
97
+ baseUrl: "https://api.example.com//",
98
+ });
99
+
100
+ await serviceFetch("/api/spaces/entities");
101
+
102
+ const [url] = fetchSpy.mock.calls[0]!;
103
+ expect(url).toBe("https://api.example.com/api/spaces/entities");
104
+ });
105
+
106
+ it("passes URL objects through correctly", async () => {
107
+ const acquireToken = vi.fn().mockResolvedValue(null);
108
+ const serviceFetch = createServiceFetch({ acquireToken });
109
+
110
+ await serviceFetch(new URL("https://example.com/api/items"));
111
+
112
+ const [url] = fetchSpy.mock.calls[0]!;
113
+ expect(url).toBe("https://example.com/api/items");
114
+ });
115
+
116
+ it("preserves Request method/body/headers when input is a Request", async () => {
117
+ const acquireToken = vi.fn().mockResolvedValue("test-token");
118
+ const serviceFetch = createServiceFetch({ acquireToken });
119
+
120
+ const req = new Request("https://example.com/api/items", {
121
+ method: "POST",
122
+ headers: { "Content-Type": "application/json", "X-Custom": "from-request" },
123
+ body: JSON.stringify({ ok: true }),
124
+ });
125
+
126
+ await serviceFetch(req);
127
+
128
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
129
+ const [url, init] = fetchSpy.mock.calls[0]!;
130
+ expect(url).toBe("https://example.com/api/items");
131
+ expect(init?.method).toBe("POST");
132
+
133
+ // Body is preserved (as a stream or buffer in the runtime).
134
+ expect(init?.body).toBeDefined();
135
+
136
+ const merged = new Headers(init?.headers);
137
+ expect(merged.get("Authorization")).toBe("Bearer test-token");
138
+ expect(merged.get("X-Custom")).toBe("from-request");
139
+ expect(merged.get("Content-Type")).toBe("application/json");
140
+ });
141
+
142
+ it("does not attach a body for GET Requests", async () => {
143
+ const acquireToken = vi.fn().mockResolvedValue(null);
144
+ const serviceFetch = createServiceFetch({ acquireToken });
145
+
146
+ const req = new Request("https://example.com/api/items", {
147
+ method: "GET",
148
+ headers: { "X-Custom": "from-request" },
149
+ });
150
+
151
+ await serviceFetch(req);
152
+
153
+ const [, init] = fetchSpy.mock.calls[0]!;
154
+ expect(init?.method).toBe("GET");
155
+ expect(init?.body).toBeUndefined();
156
+ });
157
+
158
+ it("init headers override Request headers when both supplied", async () => {
159
+ const acquireToken = vi.fn().mockResolvedValue(null);
160
+ const serviceFetch = createServiceFetch({ acquireToken });
161
+
162
+ const req = new Request("https://example.com/api/items", {
163
+ headers: { "X-Override": "from-request", "X-Request-Only": "req" },
164
+ });
165
+
166
+ await serviceFetch(req, { headers: { "X-Override": "from-init", "X-Init-Only": "init" } });
167
+
168
+ const [, init] = fetchSpy.mock.calls[0]!;
169
+ const merged = new Headers(init?.headers);
170
+ expect(merged.get("X-Override")).toBe("from-init");
171
+ expect(merged.get("X-Request-Only")).toBe("req");
172
+ expect(merged.get("X-Init-Only")).toBe("init");
173
+ });
174
+
175
+ it("recovers when acquireToken throws", async () => {
176
+ const acquireToken = vi.fn().mockRejectedValue(new Error("token boom"));
177
+ const serviceFetch = createServiceFetch({ acquireToken });
178
+
179
+ await serviceFetch("/api/spaces/entities");
180
+
181
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
182
+ const [, init] = fetchSpy.mock.calls[0]!;
183
+ const headers = new Headers(init?.headers);
184
+ expect(headers.get("Authorization")).toBeNull();
185
+ });
186
+ });
@@ -0,0 +1,22 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { streamingStatusStore } from "../store/streaming-status-store.ts";
3
+
4
+ const store = () => streamingStatusStore.getState();
5
+
6
+ describe("streamingStatusStore", () => {
7
+ beforeEach(() => {
8
+ streamingStatusStore.setState({ streamingStatus: { status: "idle" } });
9
+ });
10
+
11
+ it("starts with idle status", () => {
12
+ expect(store().streamingStatus).toEqual({ status: "idle" });
13
+ });
14
+
15
+ it("updates streaming status", () => {
16
+ store().setStreamingStatus({ status: "calling", toolName: "get_weather" });
17
+ expect(store().streamingStatus).toEqual({
18
+ status: "calling",
19
+ toolName: "get_weather",
20
+ });
21
+ });
22
+ });
@@ -0,0 +1,65 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const getAccessToken = vi.fn();
4
+
5
+ vi.mock("../msal/auth-store.ts", () => ({
6
+ authStore: {
7
+ getState: () => ({
8
+ getAccessToken,
9
+ }),
10
+ },
11
+ }));
12
+
13
+ import { tokenFetch } from "../msal/token-fetch.ts";
14
+
15
+ describe("tokenFetch", () => {
16
+ beforeEach(() => {
17
+ getAccessToken.mockReset();
18
+ vi.restoreAllMocks();
19
+ });
20
+
21
+ it("merges Request headers and lets init headers take precedence", async () => {
22
+ getAccessToken.mockResolvedValue("test-token");
23
+ const fetchSpy = vi
24
+ .spyOn(globalThis, "fetch")
25
+ .mockResolvedValue(new Response(null, { status: 200 }));
26
+
27
+ const request = new Request("https://example.com/api/items", {
28
+ headers: {
29
+ "Content-Type": "application/json",
30
+ "X-Request-Only": "request",
31
+ "X-Override": "request",
32
+ },
33
+ });
34
+
35
+ await tokenFetch(request, {
36
+ headers: {
37
+ "X-Init-Only": "init",
38
+ "X-Override": "init",
39
+ },
40
+ });
41
+
42
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
43
+ const [, init] = fetchSpy.mock.calls[0]!;
44
+ const headers = new Headers(init?.headers);
45
+
46
+ expect(headers.get("Authorization")).toBe("Bearer test-token");
47
+ expect(headers.get("Content-Type")).toBe("application/json");
48
+ expect(headers.get("X-Request-Only")).toBe("request");
49
+ expect(headers.get("X-Init-Only")).toBe("init");
50
+ expect(headers.get("X-Override")).toBe("init");
51
+ });
52
+
53
+ it("falls through to native fetch when no token is available", async () => {
54
+ getAccessToken.mockResolvedValue(null);
55
+ const fetchSpy = vi
56
+ .spyOn(globalThis, "fetch")
57
+ .mockResolvedValue(new Response(null, { status: 200 }));
58
+
59
+ await tokenFetch("https://example.com/api/items");
60
+
61
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
62
+ const [, init] = fetchSpy.mock.calls[0]!;
63
+ expect(init).toBeUndefined();
64
+ });
65
+ });
@@ -0,0 +1,330 @@
1
+ /**
2
+ * AG-UI Runner — protocol engine for `@iloveagents/foundry-agent`.
3
+ *
4
+ * Drives the AG-UI SDK's `HttpAgent.runAgent()` with an `AgentSubscriber`,
5
+ * fans the subscriber callbacks out as a normalized `RunnerEvent` stream,
6
+ * and re-issues the run with appended assistant + tool messages whenever a
7
+ * client-side tool produces a result.
8
+ *
9
+ * Framework-agnostic — no React, no `@assistant-ui/*`. The assistant-ui shim
10
+ * in `@iloveagents/foundry-web-ui` (and future Outlook / Teams / native shells)
11
+ * translate `RunnerEvent`s into their own model.
12
+ *
13
+ * @see https://docs.ag-ui.com/sdk/js/client/http-agent
14
+ * @see https://docs.ag-ui.com/sdk/js/client/subscriber
15
+ */
16
+
17
+ import { HttpAgent, type AgentSubscriber } from "@ag-ui/client";
18
+ import type { Context, Message, Tool } from "@ag-ui/core";
19
+ import type { ToolRegistry } from "../tools/registry.ts";
20
+ import type { RunnerEvent } from "./runner-events.ts";
21
+
22
+ interface ToolCallState {
23
+ id: string;
24
+ name: string;
25
+ args: string;
26
+ result?: unknown;
27
+ isError?: boolean;
28
+ followedUp?: boolean;
29
+ }
30
+
31
+ export interface AGUIRunnerOptions {
32
+ url?: string;
33
+ threadId?: string;
34
+ /** Optional override for the underlying fetch — useful for auth-attached fetch. */
35
+ fetchFn?: typeof fetch;
36
+ }
37
+
38
+ export interface AGUIRunInput {
39
+ /** AG-UI messages — caller is responsible for runtime-specific message conversion. */
40
+ messages: Message[];
41
+ /** Optional state snapshot (e.g. context items) sent to the agent. */
42
+ state?: Record<string, unknown>;
43
+ /** Tool registry consulted for client-side tool dispatch + schemas. */
44
+ registry: ToolRegistry;
45
+ /** Aborts the run — generator returns cleanly when the signal fires. */
46
+ abortSignal?: AbortSignal;
47
+ /** Optional context payload forwarded with every turn (default: empty array). */
48
+ context?: Context[];
49
+ }
50
+
51
+ /**
52
+ * Bridges callback-driven `AgentSubscriber` events into an async-iterable
53
+ * queue the runner's generator drains. Single-producer / single-consumer.
54
+ */
55
+ class EventQueue<T> {
56
+ private buffer: T[] = [];
57
+ private resolve: (() => void) | null = null;
58
+ private ended = false;
59
+ private error: unknown = null;
60
+
61
+ push(value: T): void {
62
+ this.buffer.push(value);
63
+ this.flush();
64
+ }
65
+
66
+ end(error?: unknown): void {
67
+ this.ended = true;
68
+ if (error !== undefined) this.error = error;
69
+ this.flush();
70
+ }
71
+
72
+ private flush(): void {
73
+ if (this.resolve) {
74
+ const r = this.resolve;
75
+ this.resolve = null;
76
+ r();
77
+ }
78
+ }
79
+
80
+ async *drain(): AsyncGenerator<T> {
81
+ for (;;) {
82
+ if (this.buffer.length > 0) {
83
+ yield this.buffer.shift()!;
84
+ continue;
85
+ }
86
+ if (this.ended) {
87
+ if (this.error) throw this.error;
88
+ return;
89
+ }
90
+ await new Promise<void>((r) => {
91
+ this.resolve = r;
92
+ });
93
+ }
94
+ }
95
+ }
96
+
97
+ /**
98
+ * AG-UI Runner.
99
+ *
100
+ * Reusable across runs — instantiate once, call `run()` per turn batch.
101
+ * Maintains `threadId` and the `HttpAgent` state snapshot across calls.
102
+ */
103
+ export class AGUIRunner {
104
+ private readonly httpAgent: HttpAgent;
105
+
106
+ constructor(options: AGUIRunnerOptions = {}) {
107
+ this.httpAgent = new HttpAgent({
108
+ url: options.url ?? "/api/agent",
109
+ threadId: options.threadId ?? crypto.randomUUID(),
110
+ ...(options.fetchFn ? { fetch: options.fetchFn } : {}),
111
+ });
112
+ }
113
+
114
+ get threadId(): string {
115
+ return this.httpAgent.threadId;
116
+ }
117
+
118
+ get state(): unknown {
119
+ return this.httpAgent.state;
120
+ }
121
+
122
+ /**
123
+ * Run a single AG-UI exchange (with multi-turn re-issue when client-side
124
+ * tool results are produced) and yield normalized events.
125
+ */
126
+ async *run(input: AGUIRunInput): AsyncGenerator<RunnerEvent> {
127
+ const { messages, state, registry, abortSignal, context } = input;
128
+ const toolCalls = new Map<string, ToolCallState>();
129
+ let currentMessages: Message[] = [...messages];
130
+
131
+ // Replace once at the top: subsequent turns append to currentMessages
132
+ // (per AG-UI convention — see footgun in apps/web AGENTS.md cross-link).
133
+ this.httpAgent.setMessages(currentMessages);
134
+ if (state) this.httpAgent.setState(state);
135
+
136
+ try {
137
+ for (;;) {
138
+ if (abortSignal?.aborted) return;
139
+
140
+ const tools: Tool[] = registry.getActiveSchemas();
141
+ const queue = new EventQueue<RunnerEvent>();
142
+ const runId = crypto.randomUUID();
143
+ // Per-turn text accumulator. The model can emit text BEFORE a
144
+ // tool call in the same turn ("Looking up X..." then ui_navigate);
145
+ // dropping it from the follow-up replay changes the next-turn
146
+ // context (no server-side thread state — full history rides each
147
+ // request) and causes inconsistent continuations.
148
+ let turnAssistantText = "";
149
+
150
+ // Build the request input now, before runAgent fires — dev tooling
151
+ // consumes this snapshot via the request-sent event. `runId` is
152
+ // pre-generated so the snapshot matches what runAgent emits.
153
+ const runInputSnapshot: Record<string, unknown> = {
154
+ threadId: this.httpAgent.threadId,
155
+ runId,
156
+ state: { ...((this.httpAgent.state as Record<string, unknown>) || {}), ...(state ?? {}) },
157
+ messages: currentMessages,
158
+ tools,
159
+ context: context ?? [],
160
+ };
161
+
162
+ queue.push({ type: "turn-started" });
163
+ queue.push({ type: "streaming-status", status: { status: "thinking" } });
164
+ queue.push({ type: "request-sent", input: runInputSnapshot });
165
+
166
+ const subscriber: AgentSubscriber = {
167
+ onRunStartedEvent: () => {
168
+ queue.push({ type: "run-started" });
169
+ queue.push({ type: "streaming-status", status: { status: "thinking" } });
170
+ },
171
+ onTextMessageStartEvent: () => {
172
+ queue.push({ type: "streaming-status", status: { status: "streaming" } });
173
+ },
174
+ onTextMessageContentEvent: ({ event }) => {
175
+ turnAssistantText += event.delta;
176
+ queue.push({ type: "text-delta", delta: event.delta });
177
+ },
178
+ onTextMessageEndEvent: () => {
179
+ queue.push({ type: "text-message-end" });
180
+ },
181
+ onToolCallStartEvent: ({ event }) => {
182
+ const id = event.toolCallId;
183
+ const name = event.toolCallName;
184
+ toolCalls.set(id, { id, name, args: "" });
185
+ queue.push({
186
+ type: "streaming-status",
187
+ status: { status: "calling", toolName: name },
188
+ });
189
+ queue.push({
190
+ type: "tool-call-start",
191
+ id,
192
+ name,
193
+ isClientSide: registry.isRegistered(name),
194
+ });
195
+ },
196
+ onToolCallArgsEvent: ({ event }) => {
197
+ const tc = toolCalls.get(event.toolCallId);
198
+ if (tc) tc.args += event.delta;
199
+ queue.push({ type: "tool-call-args", id: event.toolCallId, delta: event.delta });
200
+ },
201
+ onToolCallEndEvent: async ({ event }) => {
202
+ const tc = toolCalls.get(event.toolCallId);
203
+ if (!tc) return;
204
+
205
+ // Intercept client-side tools — execute locally and stash the
206
+ // result so the multi-turn loop can replay it on the next turn.
207
+ if (registry.isRegistered(tc.name)) {
208
+ try {
209
+ const resultJson = await registry.executeTool(tc.name, tc.args);
210
+ tc.result = JSON.parse(resultJson);
211
+ } catch (err) {
212
+ tc.result = {
213
+ error: err instanceof Error ? err.message : "Client-side tool failed",
214
+ };
215
+ tc.isError = true;
216
+ }
217
+ }
218
+
219
+ queue.push({
220
+ type: "tool-call-end",
221
+ id: event.toolCallId,
222
+ args: tc.args,
223
+ result: tc.result,
224
+ isError: tc.isError,
225
+ });
226
+ },
227
+ onToolCallResultEvent: ({ event }) => {
228
+ const tc = toolCalls.get(event.toolCallId);
229
+ if (!tc) return;
230
+
231
+ const content = event.content;
232
+ let parsedResult: unknown = content;
233
+ let isError = false;
234
+ try {
235
+ const parsed = JSON.parse(content);
236
+ parsedResult = parsed;
237
+ if (parsed && typeof parsed === "object" && "error" in parsed) {
238
+ isError = true;
239
+ }
240
+ } catch {
241
+ parsedResult = content;
242
+ }
243
+ tc.result = parsedResult;
244
+ tc.isError = isError;
245
+ queue.push({
246
+ type: "tool-call-result",
247
+ id: event.toolCallId,
248
+ result: parsedResult,
249
+ isError,
250
+ });
251
+ },
252
+ onRunFinishedEvent: () => {
253
+ queue.push({ type: "streaming-status", status: { status: "idle" } });
254
+ queue.push({ type: "run-finished" });
255
+ },
256
+ onRunErrorEvent: ({ event }) => {
257
+ queue.push({ type: "streaming-status", status: { status: "idle" } });
258
+ queue.push({ type: "run-error", message: event.message ?? "Run error" });
259
+ },
260
+ };
261
+
262
+ // Wire abort: AG-UI exposes abortRun(); call it when the caller's signal fires.
263
+ const onAbort = () => this.httpAgent.abortRun();
264
+ abortSignal?.addEventListener("abort", onAbort, { once: true });
265
+
266
+ // Kick off the run — completion ends the queue. Errors during the
267
+ // run surface via onRunErrorEvent; transport-level rejections
268
+ // (e.g. fetch failure) are propagated through queue.end(err).
269
+ const runPromise = this.httpAgent
270
+ .runAgent({ runId, tools, context: context ?? [] }, subscriber)
271
+ .then(() => queue.end())
272
+ .catch((err: unknown) => queue.end(err));
273
+
274
+ try {
275
+ for await (const evt of queue.drain()) {
276
+ yield evt;
277
+ if (abortSignal?.aborted) {
278
+ this.httpAgent.abortRun();
279
+ return;
280
+ }
281
+ }
282
+ } finally {
283
+ abortSignal?.removeEventListener("abort", onAbort);
284
+ await runPromise; // ensure the run task is settled
285
+ }
286
+
287
+ // Decide whether to re-issue: any client-side tool that resolved
288
+ // during this turn and hasn't been replayed yet.
289
+ const pendingClientTools = Array.from(toolCalls.values()).filter(
290
+ (tc) => registry.isRegistered(tc.name) && tc.result !== undefined && !tc.followedUp,
291
+ );
292
+ if (pendingClientTools.length === 0) break;
293
+
294
+ for (const tc of pendingClientTools) tc.followedUp = true;
295
+
296
+ // Build follow-up messages with the SAME toolCallId the agent emitted.
297
+ // Tool results are appended to the agent's message history (AG-UI
298
+ // convention — append, never replace). Preserve any pre-tool text
299
+ // the model emitted in this turn so the next-turn context matches
300
+ // what the user actually saw.
301
+ const assistantMsg: Message = {
302
+ id: crypto.randomUUID(),
303
+ role: "assistant",
304
+ content: turnAssistantText,
305
+ toolCalls: pendingClientTools.map((tc) => ({
306
+ id: tc.id,
307
+ type: "function" as const,
308
+ function: { name: tc.name, arguments: tc.args },
309
+ })),
310
+ };
311
+
312
+ const toolResultMsgs: Message[] = pendingClientTools.map((tc) => ({
313
+ id: crypto.randomUUID(),
314
+ role: "tool",
315
+ toolCallId: tc.id,
316
+ content: typeof tc.result === "string" ? tc.result : JSON.stringify(tc.result),
317
+ }));
318
+
319
+ currentMessages = [...currentMessages, assistantMsg, ...toolResultMsgs];
320
+ for (const m of [assistantMsg, ...toolResultMsgs]) {
321
+ this.httpAgent.addMessage(m);
322
+ }
323
+ }
324
+ } finally {
325
+ // Normalized terminal status — idempotent if onRunFinishedEvent already
326
+ // fired one. Consumers ignore duplicate idle transitions.
327
+ yield { type: "streaming-status", status: { status: "idle" } };
328
+ }
329
+ }
330
+ }
@@ -0,0 +1,27 @@
1
+ import type { StreamingStatus } from "../store/streaming-status-store.ts";
2
+
3
+ /**
4
+ * Normalized event stream emitted by `AGUIRunner.run()`. The runner is
5
+ * framework-agnostic; consumers (e.g. the assistant-ui shim in
6
+ * `@iloveagents/foundry-web-ui`) translate these events into their own runtime model.
7
+ *
8
+ * `request-sent` carries the exact AG-UI request payload — used by host
9
+ * dev-tooling to capture each turn.
10
+ *
11
+ * `turn-started` fires at the top of every iteration of the multi-turn loop
12
+ * (initial request + each client-tool follow-up). Consumers reset their
13
+ * per-turn buffers (e.g. `currentText`) here.
14
+ */
15
+ export type RunnerEvent =
16
+ | { type: "request-sent"; input: Record<string, unknown> }
17
+ | { type: "turn-started" }
18
+ | { type: "run-started" }
19
+ | { type: "text-delta"; delta: string }
20
+ | { type: "text-message-end" }
21
+ | { type: "tool-call-start"; id: string; name: string; isClientSide: boolean }
22
+ | { type: "tool-call-args"; id: string; delta: string }
23
+ | { type: "tool-call-end"; id: string; args: string; result?: unknown; isError?: boolean }
24
+ | { type: "tool-call-result"; id: string; result: unknown; isError: boolean }
25
+ | { type: "streaming-status"; status: StreamingStatus }
26
+ | { type: "run-finished" }
27
+ | { type: "run-error"; message: string };