@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.
- package/AGENTS.md +91 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +21 -0
- package/package.json +37 -0
- package/src/__tests__/agui-runner.test.ts +329 -0
- package/src/__tests__/auth-store.test.ts +37 -0
- package/src/__tests__/citation-store.test.ts +46 -0
- package/src/__tests__/client-tool-registry.test.ts +84 -0
- package/src/__tests__/service-fetch.test.ts +186 -0
- package/src/__tests__/streaming-status-store.test.ts +22 -0
- package/src/__tests__/token-fetch.test.ts +65 -0
- package/src/client/agui-runner.ts +330 -0
- package/src/client/runner-events.ts +27 -0
- package/src/client/service-fetch.ts +112 -0
- package/src/index.ts +28 -0
- package/src/msal/auth-config.ts +90 -0
- package/src/msal/auth-store.ts +72 -0
- package/src/msal/index.ts +10 -0
- package/src/msal/token-fetch.ts +30 -0
- package/src/store/citation-store.ts +45 -0
- package/src/store/streaming-status-store.ts +21 -0
- package/src/tools/registry.ts +112 -0
- package/tsconfig.json +15 -0
- package/vitest.config.ts +8 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Pure-TS agent transport for the LastSpace open-core stack. Published as `@iloveagents/foundry-agent`. Zero DOM, zero React, zero `@assistant-ui/*`. Cross-runtime — consumed by `@iloveagents/foundry-web-ui` today, future Outlook / Teams / native shells tomorrow.
|
|
2
|
+
|
|
3
|
+
# Architecture
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
src/
|
|
7
|
+
client/
|
|
8
|
+
agui-runner.ts ← AG-UI protocol engine (SSE + multi-turn loop). Yields RunnerEvent.
|
|
9
|
+
runner-events.ts ← RunnerEvent discriminated union.
|
|
10
|
+
service-fetch.ts ← createServiceFetch({ acquireToken, baseUrl }) factory.
|
|
11
|
+
store/
|
|
12
|
+
streaming-status-store.ts ← vanilla zustand store
|
|
13
|
+
citation-store.ts ← vanilla zustand store
|
|
14
|
+
tools/
|
|
15
|
+
registry.ts ← clientToolRegistry (vanilla zustand store).
|
|
16
|
+
msal/ ← subpath export: @iloveagents/foundry-agent/msal
|
|
17
|
+
auth-store.ts ← authStore (vanilla zustand store)
|
|
18
|
+
auth-config.ts ← MsalAuthConfig + initializeMsal singleton.
|
|
19
|
+
token-fetch.ts ← Bearer-token-attaching fetch.
|
|
20
|
+
index.ts ← barrel for the /msal subpath
|
|
21
|
+
index.ts ← barrel for the root export.
|
|
22
|
+
__tests__/ ← vitest suite (runner, service-fetch, no-react smoke).
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
# Import boundary (enforced by guard test)
|
|
26
|
+
|
|
27
|
+
Files under `packages/agent/src/` (excluding `msal/`) MUST NOT import:
|
|
28
|
+
|
|
29
|
+
- `react`, `react-dom`
|
|
30
|
+
- `@assistant-ui/*` (anywhere — including under `msal/`)
|
|
31
|
+
- `@azure/msal-*` (anywhere — including under `msal/` for `msal-react`; only `@azure/msal-browser` is allowed under `msal/`)
|
|
32
|
+
- bare `"zustand"` — use `"zustand/vanilla"` (`zustand/middleware` is allowed)
|
|
33
|
+
|
|
34
|
+
Files under `packages/agent/src/msal/` MAY import `@azure/msal-browser` (dynamic import only — keeps the dep optional). They MUST NOT import `@azure/msal-react` (which is React-bound and lives in `@iloveagents/foundry-web-ui`).
|
|
35
|
+
|
|
36
|
+
`packages/agent/` MUST NOT import from any other `@lastspace/*` package — agent is the leaf of the open-core graph.
|
|
37
|
+
|
|
38
|
+
# Why `zustand/vanilla`
|
|
39
|
+
|
|
40
|
+
`import { create } from "zustand"` resolves to the React build (`useSyncExternalStore`). Importing it in a "zero React" package would silently take a React dependency and defeat the cross-runtime goal. Every store in this package uses `import { createStore } from "zustand/vanilla"`. React consumers (in `@iloveagents/foundry-web-ui` / `apps/web/`) bind via `useStore(store, selector)` from the React entry of zustand.
|
|
41
|
+
|
|
42
|
+
# What goes here
|
|
43
|
+
|
|
44
|
+
- AG-UI protocol client (SSE, message conversion, multi-turn re-issue, tool dispatch).
|
|
45
|
+
- Vanilla state stores that the protocol engine needs at runtime — streaming status, citation cache, client-tool registry.
|
|
46
|
+
- MSAL bits under the `/msal` subpath only.
|
|
47
|
+
- Generic per-service fetch factory (`createServiceFetch`).
|
|
48
|
+
|
|
49
|
+
# What does NOT go here
|
|
50
|
+
|
|
51
|
+
- Anything implementing `@assistant-ui/*` interfaces (`ChatModelAdapter`, `AttachmentAdapter`, etc.) — those are React UI surface and live in `@iloveagents/foundry-web-ui`.
|
|
52
|
+
- React components, hooks, contexts, providers.
|
|
53
|
+
- UI-layout state (panel widths, modal modes, theme).
|
|
54
|
+
- The `MsalProvider` React component — that lives in `@iloveagents/foundry-web-ui`.
|
|
55
|
+
|
|
56
|
+
# Public surface
|
|
57
|
+
|
|
58
|
+
Root `@iloveagents/foundry-agent`:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import {
|
|
62
|
+
AGUIRunner,
|
|
63
|
+
type RunnerEvent,
|
|
64
|
+
createServiceFetch,
|
|
65
|
+
clientToolRegistry,
|
|
66
|
+
type ClientToolEntry,
|
|
67
|
+
streamingStatusStore,
|
|
68
|
+
type StreamingStatus,
|
|
69
|
+
citationStore,
|
|
70
|
+
type CitationResult,
|
|
71
|
+
type CitationHandler,
|
|
72
|
+
} from "@iloveagents/foundry-agent";
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Subpath `@iloveagents/foundry-agent/msal`:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
import {
|
|
79
|
+
authStore,
|
|
80
|
+
type AuthUser,
|
|
81
|
+
type MsalAuthConfig,
|
|
82
|
+
initializeMsal,
|
|
83
|
+
getMsalInstance,
|
|
84
|
+
getMsalConfig,
|
|
85
|
+
tokenFetch,
|
|
86
|
+
} from "@iloveagents/foundry-agent/msal";
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
# Bundle-size budget
|
|
90
|
+
|
|
91
|
+
Target ≤25 KB gz on the root entry (`@iloveagents/foundry-agent`, excluding the `/msal` subpath). Measured manually pre-1.0 via `gzip -c dist/index.js | wc -c`; `size-limit` enforcement scaffolds in #92's follow-up.
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@AGENTS.md
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 iLoveAgents, a brand of Leitwolf GmbH
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@iloveagents/foundry-agent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"types": "./src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./msal": "./src/msal/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"peerDependencies": {
|
|
15
|
+
"zustand": "^5.0.0",
|
|
16
|
+
"@ag-ui/client": "^0.0.52",
|
|
17
|
+
"@ag-ui/core": "^0.0.52",
|
|
18
|
+
"@azure/msal-browser": "^5.0.0"
|
|
19
|
+
},
|
|
20
|
+
"peerDependenciesMeta": {
|
|
21
|
+
"@azure/msal-browser": {
|
|
22
|
+
"optional": true
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"typescript": "~5.9.3",
|
|
27
|
+
"vitest": "^4.1.4",
|
|
28
|
+
"jsdom": "^28.1.0",
|
|
29
|
+
"zustand": "^5.0.0",
|
|
30
|
+
"@ag-ui/client": "^0.0.52",
|
|
31
|
+
"@ag-ui/core": "^0.0.52"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test:unit": "vitest run",
|
|
35
|
+
"typecheck": "tsc --noEmit"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { AgentSubscriber } from "@ag-ui/client";
|
|
3
|
+
|
|
4
|
+
// Scripted event playback for the mocked HttpAgent. Each test sets `script`
|
|
5
|
+
// to an array of (subscriber) => Promise<void> functions. The mock's
|
|
6
|
+
// runAgent fires them in order, synchronously yielding the loop between
|
|
7
|
+
// each one so the runner's queue can drain.
|
|
8
|
+
|
|
9
|
+
type ScriptedRun = (subscriber: AgentSubscriber) => Promise<void>;
|
|
10
|
+
|
|
11
|
+
let nextScript: ScriptedRun[] = [];
|
|
12
|
+
let runCount = 0;
|
|
13
|
+
let lastMessages: unknown[] = [];
|
|
14
|
+
|
|
15
|
+
vi.mock("@ag-ui/client", () => {
|
|
16
|
+
class MockHttpAgent {
|
|
17
|
+
threadId: string;
|
|
18
|
+
state: Record<string, unknown> = {};
|
|
19
|
+
messages: unknown[] = [];
|
|
20
|
+
|
|
21
|
+
constructor(opts: { url?: string; threadId?: string }) {
|
|
22
|
+
this.threadId = opts.threadId ?? "thread-1";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
setMessages(messages: unknown[]) {
|
|
26
|
+
this.messages = [...messages];
|
|
27
|
+
lastMessages = this.messages;
|
|
28
|
+
}
|
|
29
|
+
setState(state: Record<string, unknown>) {
|
|
30
|
+
this.state = state;
|
|
31
|
+
}
|
|
32
|
+
addMessage(message: unknown) {
|
|
33
|
+
this.messages.push(message);
|
|
34
|
+
lastMessages = this.messages;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async runAgent(_params: unknown, subscriber: AgentSubscriber): Promise<void> {
|
|
38
|
+
const turnIdx = runCount;
|
|
39
|
+
runCount++;
|
|
40
|
+
const script = nextScript[turnIdx];
|
|
41
|
+
if (script) await script(subscriber);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
abortRun() {
|
|
45
|
+
// no-op for tests
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { HttpAgent: MockHttpAgent };
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
import { AGUIRunner } from "../client/agui-runner.ts";
|
|
52
|
+
import { clientToolRegistry } from "../tools/registry.ts";
|
|
53
|
+
import type { RunnerEvent } from "../client/runner-events.ts";
|
|
54
|
+
|
|
55
|
+
function makeRegistry(client: { isRegistered: (name: string) => boolean; executeTool?: (name: string, args: string) => Promise<string> }) {
|
|
56
|
+
return {
|
|
57
|
+
isRegistered: client.isRegistered,
|
|
58
|
+
executeTool: client.executeTool ?? (async () => JSON.stringify({})),
|
|
59
|
+
getActiveSchemas: () => [],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function collect(runner: AGUIRunner, registry: ReturnType<typeof makeRegistry>): Promise<RunnerEvent[]> {
|
|
64
|
+
const out: RunnerEvent[] = [];
|
|
65
|
+
for await (const evt of runner.run({ messages: [], registry })) {
|
|
66
|
+
out.push(evt);
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("AGUIRunner", () => {
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
nextScript = [];
|
|
74
|
+
runCount = 0;
|
|
75
|
+
lastMessages = [];
|
|
76
|
+
clientToolRegistry.setState({ globalTools: new Map(), pageTools: new Map() });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("emits the canonical event sequence for a text-only run", async () => {
|
|
80
|
+
nextScript = [
|
|
81
|
+
async (s) => {
|
|
82
|
+
await s.onRunStartedEvent?.({ event: { type: "RUN_STARTED" } } as never);
|
|
83
|
+
await s.onTextMessageStartEvent?.({ event: { type: "TEXT_MESSAGE_START" } } as never);
|
|
84
|
+
await s.onTextMessageContentEvent?.({
|
|
85
|
+
event: { type: "TEXT_MESSAGE_CONTENT", delta: "Hello" } as never,
|
|
86
|
+
} as never);
|
|
87
|
+
await s.onTextMessageContentEvent?.({
|
|
88
|
+
event: { type: "TEXT_MESSAGE_CONTENT", delta: " world" } as never,
|
|
89
|
+
} as never);
|
|
90
|
+
await s.onTextMessageEndEvent?.({ event: { type: "TEXT_MESSAGE_END" } } as never);
|
|
91
|
+
await s.onRunFinishedEvent?.({ event: { type: "RUN_FINISHED" } } as never);
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
const runner = new AGUIRunner();
|
|
96
|
+
const events = await collect(runner, makeRegistry({ isRegistered: () => false }));
|
|
97
|
+
|
|
98
|
+
const types = events.map((e) => e.type);
|
|
99
|
+
expect(types).toContain("turn-started");
|
|
100
|
+
expect(types).toContain("request-sent");
|
|
101
|
+
expect(types).toContain("run-started");
|
|
102
|
+
expect(types).toContain("text-delta");
|
|
103
|
+
expect(types).toContain("text-message-end");
|
|
104
|
+
expect(types).toContain("run-finished");
|
|
105
|
+
|
|
106
|
+
const deltas = events.filter((e): e is Extract<RunnerEvent, { type: "text-delta" }> =>
|
|
107
|
+
e.type === "text-delta",
|
|
108
|
+
);
|
|
109
|
+
expect(deltas.map((d) => d.delta).join("")).toBe("Hello world");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("emits tool-call events with isClientSide flag", async () => {
|
|
113
|
+
nextScript = [
|
|
114
|
+
async (s) => {
|
|
115
|
+
await s.onRunStartedEvent?.({ event: { type: "RUN_STARTED" } } as never);
|
|
116
|
+
await s.onToolCallStartEvent?.({
|
|
117
|
+
event: {
|
|
118
|
+
type: "TOOL_CALL_START",
|
|
119
|
+
toolCallId: "tc-1",
|
|
120
|
+
toolCallName: "server_tool",
|
|
121
|
+
} as never,
|
|
122
|
+
} as never);
|
|
123
|
+
await s.onToolCallArgsEvent?.({
|
|
124
|
+
event: { type: "TOOL_CALL_ARGS", toolCallId: "tc-1", delta: "{\"q\":\"x\"}" } as never,
|
|
125
|
+
} as never);
|
|
126
|
+
await s.onToolCallEndEvent?.({
|
|
127
|
+
event: { type: "TOOL_CALL_END", toolCallId: "tc-1" } as never,
|
|
128
|
+
} as never);
|
|
129
|
+
await s.onToolCallResultEvent?.({
|
|
130
|
+
event: {
|
|
131
|
+
type: "TOOL_CALL_RESULT",
|
|
132
|
+
toolCallId: "tc-1",
|
|
133
|
+
content: '{"ok":true}',
|
|
134
|
+
} as never,
|
|
135
|
+
} as never);
|
|
136
|
+
await s.onRunFinishedEvent?.({ event: { type: "RUN_FINISHED" } } as never);
|
|
137
|
+
},
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
const runner = new AGUIRunner();
|
|
141
|
+
const events = await collect(runner, makeRegistry({ isRegistered: () => false }));
|
|
142
|
+
|
|
143
|
+
const start = events.find((e): e is Extract<RunnerEvent, { type: "tool-call-start" }> =>
|
|
144
|
+
e.type === "tool-call-start",
|
|
145
|
+
);
|
|
146
|
+
expect(start).toBeDefined();
|
|
147
|
+
expect(start!.isClientSide).toBe(false);
|
|
148
|
+
expect(start!.name).toBe("server_tool");
|
|
149
|
+
|
|
150
|
+
const result = events.find(
|
|
151
|
+
(e): e is Extract<RunnerEvent, { type: "tool-call-result" }> => e.type === "tool-call-result",
|
|
152
|
+
);
|
|
153
|
+
expect(result).toBeDefined();
|
|
154
|
+
expect(result!.result).toEqual({ ok: true });
|
|
155
|
+
expect(result!.isError).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("intercepts client-side tools and re-issues the run with the result", async () => {
|
|
159
|
+
const executeTool = vi.fn().mockResolvedValue('{"navigated":true}');
|
|
160
|
+
|
|
161
|
+
nextScript = [
|
|
162
|
+
async (s) => {
|
|
163
|
+
await s.onRunStartedEvent?.({ event: { type: "RUN_STARTED" } } as never);
|
|
164
|
+
await s.onToolCallStartEvent?.({
|
|
165
|
+
event: {
|
|
166
|
+
type: "TOOL_CALL_START",
|
|
167
|
+
toolCallId: "tc-1",
|
|
168
|
+
toolCallName: "ui_navigate",
|
|
169
|
+
} as never,
|
|
170
|
+
} as never);
|
|
171
|
+
await s.onToolCallArgsEvent?.({
|
|
172
|
+
event: {
|
|
173
|
+
type: "TOOL_CALL_ARGS",
|
|
174
|
+
toolCallId: "tc-1",
|
|
175
|
+
delta: '{"path":"/foo"}',
|
|
176
|
+
} as never,
|
|
177
|
+
} as never);
|
|
178
|
+
await s.onToolCallEndEvent?.({
|
|
179
|
+
event: { type: "TOOL_CALL_END", toolCallId: "tc-1" } as never,
|
|
180
|
+
} as never);
|
|
181
|
+
await s.onRunFinishedEvent?.({ event: { type: "RUN_FINISHED" } } as never);
|
|
182
|
+
},
|
|
183
|
+
async (s) => {
|
|
184
|
+
// Second turn after follow-up: agent acknowledges with text + finishes.
|
|
185
|
+
await s.onRunStartedEvent?.({ event: { type: "RUN_STARTED" } } as never);
|
|
186
|
+
await s.onTextMessageStartEvent?.({ event: { type: "TEXT_MESSAGE_START" } } as never);
|
|
187
|
+
await s.onTextMessageContentEvent?.({
|
|
188
|
+
event: { type: "TEXT_MESSAGE_CONTENT", delta: "Done" } as never,
|
|
189
|
+
} as never);
|
|
190
|
+
await s.onTextMessageEndEvent?.({ event: { type: "TEXT_MESSAGE_END" } } as never);
|
|
191
|
+
await s.onRunFinishedEvent?.({ event: { type: "RUN_FINISHED" } } as never);
|
|
192
|
+
},
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
const runner = new AGUIRunner();
|
|
196
|
+
const events = await collect(
|
|
197
|
+
runner,
|
|
198
|
+
makeRegistry({
|
|
199
|
+
isRegistered: (name) => name === "ui_navigate",
|
|
200
|
+
executeTool,
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
expect(executeTool).toHaveBeenCalledWith("ui_navigate", '{"path":"/foo"}');
|
|
205
|
+
expect(runCount).toBe(2); // Initial + follow-up
|
|
206
|
+
|
|
207
|
+
// Second turn was issued with the same toolCallId in the assistant message
|
|
208
|
+
const lastAssistantMsg = lastMessages.find(
|
|
209
|
+
(m): m is { role: string; toolCalls?: Array<{ id: string }> } =>
|
|
210
|
+
typeof m === "object" && m !== null && (m as { role?: string }).role === "assistant",
|
|
211
|
+
);
|
|
212
|
+
expect(lastAssistantMsg?.toolCalls?.[0]?.id).toBe("tc-1");
|
|
213
|
+
|
|
214
|
+
// tool-call-start should have isClientSide=true
|
|
215
|
+
const start = events.find((e): e is Extract<RunnerEvent, { type: "tool-call-start" }> =>
|
|
216
|
+
e.type === "tool-call-start",
|
|
217
|
+
);
|
|
218
|
+
expect(start!.isClientSide).toBe(true);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("preserves pre-tool assistant text in the follow-up replay", async () => {
|
|
222
|
+
// Regression: if the model emits text before the tool call in the
|
|
223
|
+
// same turn ("Looking up..." then ui_navigate), the follow-up
|
|
224
|
+
// assistant message must include that text. Otherwise the next-turn
|
|
225
|
+
// context drifts (no server-side thread state — full history rides
|
|
226
|
+
// each request).
|
|
227
|
+
const executeTool = vi.fn().mockResolvedValue('{"ok":true}');
|
|
228
|
+
|
|
229
|
+
nextScript = [
|
|
230
|
+
async (s) => {
|
|
231
|
+
await s.onRunStartedEvent?.({ event: { type: "RUN_STARTED" } } as never);
|
|
232
|
+
await s.onTextMessageStartEvent?.({ event: { type: "TEXT_MESSAGE_START" } } as never);
|
|
233
|
+
await s.onTextMessageContentEvent?.({
|
|
234
|
+
event: { type: "TEXT_MESSAGE_CONTENT", delta: "Navigating..." } as never,
|
|
235
|
+
} as never);
|
|
236
|
+
await s.onTextMessageEndEvent?.({ event: { type: "TEXT_MESSAGE_END" } } as never);
|
|
237
|
+
await s.onToolCallStartEvent?.({
|
|
238
|
+
event: {
|
|
239
|
+
type: "TOOL_CALL_START",
|
|
240
|
+
toolCallId: "tc-pre",
|
|
241
|
+
toolCallName: "ui_navigate",
|
|
242
|
+
} as never,
|
|
243
|
+
} as never);
|
|
244
|
+
await s.onToolCallArgsEvent?.({
|
|
245
|
+
event: {
|
|
246
|
+
type: "TOOL_CALL_ARGS",
|
|
247
|
+
toolCallId: "tc-pre",
|
|
248
|
+
delta: '{"path":"/x"}',
|
|
249
|
+
} as never,
|
|
250
|
+
} as never);
|
|
251
|
+
await s.onToolCallEndEvent?.({
|
|
252
|
+
event: { type: "TOOL_CALL_END", toolCallId: "tc-pre" } as never,
|
|
253
|
+
} as never);
|
|
254
|
+
await s.onRunFinishedEvent?.({ event: { type: "RUN_FINISHED" } } as never);
|
|
255
|
+
},
|
|
256
|
+
async (s) => {
|
|
257
|
+
await s.onRunStartedEvent?.({ event: { type: "RUN_STARTED" } } as never);
|
|
258
|
+
await s.onRunFinishedEvent?.({ event: { type: "RUN_FINISHED" } } as never);
|
|
259
|
+
},
|
|
260
|
+
];
|
|
261
|
+
|
|
262
|
+
const runner = new AGUIRunner();
|
|
263
|
+
await collect(
|
|
264
|
+
runner,
|
|
265
|
+
makeRegistry({
|
|
266
|
+
isRegistered: (name) => name === "ui_navigate",
|
|
267
|
+
executeTool,
|
|
268
|
+
}),
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
expect(runCount).toBe(2);
|
|
272
|
+
const lastAssistant = lastMessages.find(
|
|
273
|
+
(m): m is { role: string; content: string; toolCalls?: Array<{ id: string }> } =>
|
|
274
|
+
typeof m === "object" && m !== null && (m as { role?: string }).role === "assistant",
|
|
275
|
+
);
|
|
276
|
+
expect(lastAssistant?.content).toBe("Navigating...");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("yields turn-started exactly once per run iteration", async () => {
|
|
280
|
+
nextScript = [
|
|
281
|
+
async (s) => {
|
|
282
|
+
await s.onRunStartedEvent?.({ event: { type: "RUN_STARTED" } } as never);
|
|
283
|
+
await s.onRunFinishedEvent?.({ event: { type: "RUN_FINISHED" } } as never);
|
|
284
|
+
},
|
|
285
|
+
];
|
|
286
|
+
|
|
287
|
+
const runner = new AGUIRunner();
|
|
288
|
+
const events = await collect(runner, makeRegistry({ isRegistered: () => false }));
|
|
289
|
+
|
|
290
|
+
const turnStarts = events.filter((e) => e.type === "turn-started");
|
|
291
|
+
expect(turnStarts.length).toBe(1);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("emits request-sent before any subscriber events", async () => {
|
|
295
|
+
nextScript = [
|
|
296
|
+
async (s) => {
|
|
297
|
+
await s.onRunFinishedEvent?.({ event: { type: "RUN_FINISHED" } } as never);
|
|
298
|
+
},
|
|
299
|
+
];
|
|
300
|
+
|
|
301
|
+
const runner = new AGUIRunner();
|
|
302
|
+
const events = await collect(runner, makeRegistry({ isRegistered: () => false }));
|
|
303
|
+
|
|
304
|
+
const requestIdx = events.findIndex((e) => e.type === "request-sent");
|
|
305
|
+
const finishedIdx = events.findIndex((e) => e.type === "run-finished");
|
|
306
|
+
expect(requestIdx).toBeGreaterThanOrEqual(0);
|
|
307
|
+
expect(requestIdx).toBeLessThan(finishedIdx);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("translates RUN_ERROR into run-error with the message", async () => {
|
|
311
|
+
nextScript = [
|
|
312
|
+
async (s) => {
|
|
313
|
+
await s.onRunStartedEvent?.({ event: { type: "RUN_STARTED" } } as never);
|
|
314
|
+
await s.onRunErrorEvent?.({
|
|
315
|
+
event: { type: "RUN_ERROR", message: "boom" } as never,
|
|
316
|
+
} as never);
|
|
317
|
+
},
|
|
318
|
+
];
|
|
319
|
+
|
|
320
|
+
const runner = new AGUIRunner();
|
|
321
|
+
const events = await collect(runner, makeRegistry({ isRegistered: () => false }));
|
|
322
|
+
|
|
323
|
+
const err = events.find((e): e is Extract<RunnerEvent, { type: "run-error" }> =>
|
|
324
|
+
e.type === "run-error",
|
|
325
|
+
);
|
|
326
|
+
expect(err).toBeDefined();
|
|
327
|
+
expect(err!.message).toBe("boom");
|
|
328
|
+
});
|
|
329
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { authStore } from "../msal/auth-store.ts";
|
|
3
|
+
|
|
4
|
+
const store = () => authStore.getState();
|
|
5
|
+
|
|
6
|
+
describe("authStore", () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
authStore.setState({ user: null, isAuthenticated: false });
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("starts unauthenticated", () => {
|
|
12
|
+
expect(store().isAuthenticated).toBe(false);
|
|
13
|
+
expect(store().user).toBeNull();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("signs in with a user", () => {
|
|
17
|
+
store().signIn({ name: "Alice", email: "alice@example.com" });
|
|
18
|
+
expect(store().isAuthenticated).toBe(true);
|
|
19
|
+
expect(store().user?.name).toBe("Alice");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("signs out and clears user", () => {
|
|
23
|
+
store().signIn({ name: "Alice", email: "alice@example.com" });
|
|
24
|
+
store().signOut();
|
|
25
|
+
expect(store().isAuthenticated).toBe(false);
|
|
26
|
+
expect(store().user).toBeNull();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("signs in with avatar", () => {
|
|
30
|
+
store().signIn({
|
|
31
|
+
name: "Bob",
|
|
32
|
+
email: "bob@example.com",
|
|
33
|
+
avatar: "https://example.com/bob.png",
|
|
34
|
+
});
|
|
35
|
+
expect(store().user?.avatar).toBe("https://example.com/bob.png");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { citationStore } from "../store/citation-store.ts";
|
|
3
|
+
import type { CitationResult, CitationHandler } from "../store/citation-store.ts";
|
|
4
|
+
|
|
5
|
+
const mockResult: CitationResult = {
|
|
6
|
+
chunk_id: "chunk-1",
|
|
7
|
+
entity_id: "e-1",
|
|
8
|
+
entity_name: "Test Doc",
|
|
9
|
+
content: "Some content",
|
|
10
|
+
page_number: 1,
|
|
11
|
+
bounding_regions: "0,0,100,100",
|
|
12
|
+
score: 0.95,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
describe("citationStore", () => {
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
citationStore.setState({ results: [], handler: null });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("setResults", () => {
|
|
21
|
+
it("stores citation results", () => {
|
|
22
|
+
citationStore.getState().setResults([mockResult]);
|
|
23
|
+
expect(citationStore.getState().results).toEqual([mockResult]);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("setHandler", () => {
|
|
28
|
+
it("registers a citation handler", () => {
|
|
29
|
+
const handler: CitationHandler = { openCitation: () => {} };
|
|
30
|
+
citationStore.getState().setHandler(handler);
|
|
31
|
+
expect(citationStore.getState().handler).toBe(handler);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("clear", () => {
|
|
36
|
+
it("resets results and handler", () => {
|
|
37
|
+
citationStore.getState().setResults([mockResult]);
|
|
38
|
+
citationStore.getState().setHandler({ openCitation: () => {} });
|
|
39
|
+
|
|
40
|
+
citationStore.getState().clear();
|
|
41
|
+
|
|
42
|
+
expect(citationStore.getState().results).toEqual([]);
|
|
43
|
+
expect(citationStore.getState().handler).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { clientToolRegistry } from "../tools/registry.ts";
|
|
3
|
+
import type { ClientToolEntry } from "../tools/registry.ts";
|
|
4
|
+
|
|
5
|
+
function makeTool(name: string): ClientToolEntry {
|
|
6
|
+
return {
|
|
7
|
+
name,
|
|
8
|
+
description: `${name} tool`,
|
|
9
|
+
parameters: { type: "object", properties: {} },
|
|
10
|
+
execute: async () => JSON.stringify({ ok: true }),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("clientToolRegistry", () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
clientToolRegistry.setState({ globalTools: new Map(), pageTools: new Map() });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("registerGlobal", () => {
|
|
20
|
+
it("adds a global tool", () => {
|
|
21
|
+
clientToolRegistry.getState().registerGlobal(makeTool("ui_navigate"));
|
|
22
|
+
expect(clientToolRegistry.getState().isRegistered("ui_navigate")).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("registerPageTools / clearPageTools", () => {
|
|
27
|
+
it("registers page-scoped tools", () => {
|
|
28
|
+
clientToolRegistry.getState().registerPageTools([makeTool("page_search")]);
|
|
29
|
+
expect(clientToolRegistry.getState().isRegistered("page_search")).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("clears page tools", () => {
|
|
33
|
+
clientToolRegistry.getState().registerPageTools([makeTool("page_search")]);
|
|
34
|
+
clientToolRegistry.getState().clearPageTools();
|
|
35
|
+
expect(clientToolRegistry.getState().isRegistered("page_search")).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("getActiveSchemas", () => {
|
|
40
|
+
it("merges global and page tools", () => {
|
|
41
|
+
clientToolRegistry.getState().registerGlobal(makeTool("ui_navigate"));
|
|
42
|
+
clientToolRegistry.getState().registerPageTools([makeTool("page_search")]);
|
|
43
|
+
const schemas = clientToolRegistry.getState().getActiveSchemas();
|
|
44
|
+
expect(schemas.map((s) => s.name).sort()).toEqual(["page_search", "ui_navigate"]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("page tools override global tools with same name", () => {
|
|
48
|
+
clientToolRegistry.getState().registerGlobal(makeTool("shared_tool"));
|
|
49
|
+
const pageTool = makeTool("shared_tool");
|
|
50
|
+
pageTool.description = "page version";
|
|
51
|
+
clientToolRegistry.getState().registerPageTools([pageTool]);
|
|
52
|
+
|
|
53
|
+
const schemas = clientToolRegistry.getState().getActiveSchemas();
|
|
54
|
+
const match = schemas.find((s) => s.name === "shared_tool");
|
|
55
|
+
expect(match?.description).toBe("page version");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("executeTool", () => {
|
|
60
|
+
it("executes a registered tool", async () => {
|
|
61
|
+
clientToolRegistry.getState().registerGlobal(makeTool("ui_navigate"));
|
|
62
|
+
const result = await clientToolRegistry.getState().executeTool("ui_navigate", "{}");
|
|
63
|
+
expect(JSON.parse(result)).toEqual({ ok: true });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("returns error for unknown tool", async () => {
|
|
67
|
+
const result = await clientToolRegistry.getState().executeTool("unknown_tool", "{}");
|
|
68
|
+
expect(JSON.parse(result).error).toContain("Unknown client tool");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("page tool takes precedence over global", async () => {
|
|
72
|
+
const globalTool = makeTool("shared");
|
|
73
|
+
globalTool.execute = async () => "global";
|
|
74
|
+
const pageTool = makeTool("shared");
|
|
75
|
+
pageTool.execute = async () => "page";
|
|
76
|
+
|
|
77
|
+
clientToolRegistry.getState().registerGlobal(globalTool);
|
|
78
|
+
clientToolRegistry.getState().registerPageTools([pageTool]);
|
|
79
|
+
|
|
80
|
+
const result = await clientToolRegistry.getState().executeTool("shared", "{}");
|
|
81
|
+
expect(result).toBe("page");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
});
|