@alexkroman1/aai-cli 7.0.0 → 8.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/README.md +1 -0
- package/dist/{_bundler-B4RqNF5Z.mjs → _bundler-CDuYl5Gb.mjs} +1 -1
- package/dist/{_dev-server-CiKFH9qw.mjs → _dev-server-Dt3TCjhj.mjs} +6 -2
- package/dist/{_init-CE4HKdgV.mjs → _init-BhEfQ6Yi.mjs} +1 -1
- package/dist/_resource-commands.d.ts +23 -0
- package/dist/{_slug-api-nRFaBEPJ.mjs → _slug-api-z-USxPtL.mjs} +14 -10
- package/dist/_slug-api.d.ts +5 -2
- package/dist/{_templates-FI3xPEMj.mjs → _templates-4WcKOjS5.mjs} +4 -5
- package/dist/_templates.d.ts +3 -4
- package/dist/{build-6jfaakgc.mjs → build-BBxf8vom.mjs} +2 -2
- package/dist/cli.mjs +338 -346
- package/dist/{deploy-DvYeD8ia.mjs → deploy-DRC8jWr8.mjs} +1 -1
- package/dist/{dev-B59JIfWO.mjs → dev-QuuOOUtv.mjs} +1 -1
- package/dist/eval-DfQ8cm5p.mjs +56 -0
- package/dist/eval.d.ts +43 -0
- package/dist/{init-CpJVjEg0.mjs → init-CUo87gcI.mjs} +2 -2
- package/dist/{logs-9q-psa4q.mjs → logs-CQ2T46dr.mjs} +1 -1
- package/dist/scaffold/CLAUDE.md +409 -68
- package/dist/scaffold/package.json +5 -4
- package/dist/scaffold/server.mjs +10 -3
- package/dist/scaffold/tsconfig.json +0 -1
- package/dist/{secret-CVvSLIDV.mjs → secret-Ct34kdgU.mjs} +1 -1
- package/dist/{storage-BvUrnvM3.mjs → storage-i_79ZwAR.mjs} +1 -1
- package/dist/{studio--MUV0cid.mjs → studio-CzTzPqe1.mjs} +1 -1
- package/dist/templates/briefing-desk/agent.test.ts +350 -0
- package/dist/templates/briefing-desk/agent.ts +30 -0
- package/dist/templates/briefing-desk/shared.ts +200 -0
- package/dist/templates/briefing-desk/system-prompt.md +39 -0
- package/dist/templates/briefing-desk/tools/briefing_so_far.ts +35 -0
- package/dist/templates/briefing-desk/tools/research_topic.ts +88 -0
- package/dist/templates/briefing-desk/tools/verify_claim.ts +77 -0
- package/dist/templates/call-audit/agent.eval.test.ts +474 -0
- package/dist/templates/code-interpreter/agent.eval.test.ts +165 -0
- package/dist/templates/dispatch-center/agent.eval.test.ts +349 -0
- package/dist/templates/embedded-assets/agent.eval.test.ts +138 -0
- package/dist/templates/health-assistant/agent.eval.test.ts +167 -0
- package/dist/templates/infocom-adventure/agent.eval.test.ts +158 -0
- package/dist/templates/infocom-adventure/agent.test.ts +49 -18
- package/dist/templates/infocom-adventure/agent.ts +21 -0
- package/dist/templates/infocom-adventure/shared.ts +20 -9
- package/dist/templates/infocom-adventure/system-prompt.md +1 -1
- package/dist/templates/infocom-adventure/tools/game_state_move.ts +3 -2
- package/dist/templates/link-digest/agent.eval.test.ts +195 -0
- package/dist/templates/math-buddy/agent.eval.test.ts +146 -0
- package/dist/templates/night-owl/agent.eval.test.ts +272 -0
- package/dist/templates/personal-finance/agent.eval.test.ts +168 -0
- package/dist/templates/personal-finance/system-prompt.md +4 -0
- package/dist/templates/pipeline-simple/agent.eval.test.ts +78 -0
- package/dist/templates/pipeline-simple/agent.ts +2 -2
- package/dist/templates/pizza-ordering/agent.eval.test.ts +171 -0
- package/dist/templates/pizza-ordering/agent.test.ts +15 -0
- package/dist/templates/pizza-ordering/shared.ts +20 -1
- package/dist/templates/pizza-ordering/system-prompt.md +6 -0
- package/dist/templates/plan-and-execute/agent.eval.test.ts +209 -0
- package/dist/templates/podcast-digest/agent.eval.test.ts +411 -0
- package/dist/templates/podcast-digest/agent.test.ts +30 -59
- package/dist/templates/podcast-digest/agent.ts +1 -1
- package/dist/templates/podcast-digest/workflows/digest.ts +52 -19
- package/dist/templates/podcast-digest/workflows/slack.ts +48 -169
- package/dist/templates/recap-workflow/agent.eval.test.ts +366 -0
- package/dist/templates/redline/agent.eval.test.ts +209 -0
- package/dist/templates/research-workflow/agent.eval.test.ts +329 -0
- package/dist/templates/retail/address.ts +87 -1
- package/dist/templates/retail/agent.eval.test.ts +314 -0
- package/dist/templates/retail/agent.test.ts +171 -91
- package/dist/templates/retail/agent.ts +35 -1
- package/dist/templates/retail/cancel.ts +116 -0
- package/dist/templates/retail/client.tsx +39 -0
- package/dist/templates/retail/payment.ts +107 -0
- package/dist/templates/retail/pending.test.ts +196 -0
- package/dist/templates/retail/pending.ts +137 -0
- package/dist/templates/retail/registry.test.ts +119 -8
- package/dist/templates/retail/returns.ts +102 -0
- package/dist/templates/retail/shared.test.ts +42 -0
- package/dist/templates/retail/shared.ts +35 -0
- package/dist/templates/retail/store.test.ts +6 -4
- package/dist/templates/retail/store.ts +113 -57
- package/dist/templates/retail/swap.test.ts +4 -4
- package/dist/templates/retail/swap.ts +248 -15
- package/dist/templates/retail/system-prompt.md +62 -30
- package/dist/templates/retail/tools/cancel_change.ts +33 -0
- package/dist/templates/retail/tools/cancel_pending_order.ts +15 -59
- package/dist/templates/retail/tools/confirm_change.ts +42 -0
- package/dist/templates/retail/tools/exchange_delivered_order_items.ts +21 -66
- package/dist/templates/retail/tools/modify_pending_order_address.ts +12 -24
- package/dist/templates/retail/tools/modify_pending_order_items.ts +22 -60
- package/dist/templates/retail/tools/modify_pending_order_payment.ts +15 -77
- package/dist/templates/retail/tools/modify_user_address.ts +12 -15
- package/dist/templates/retail/tools/return_delivered_order_items.ts +16 -71
- package/dist/templates/retail/tools/transfer_to_human_agents.ts +4 -2
- package/dist/templates/simple/agent.eval.test.ts +54 -0
- package/dist/templates/solo-rpg/agent.eval.test.ts +320 -0
- package/dist/templates/spoken-summary/agent.eval.test.ts +342 -0
- package/dist/templates/support-line/agent.eval.test.ts +273 -0
- package/dist/templates/support-line/system-prompt.md +2 -1
- package/dist/templates/support-line/tools/answer_question.ts +56 -7
- package/dist/templates/transcription-workflow/agent.eval.test.ts +234 -0
- package/dist/templates/travel-concierge/agent.eval.test.ts +323 -0
- package/dist/templates/travel-concierge/agent.test.ts +110 -10
- package/dist/templates/travel-concierge/agent.ts +18 -9
- package/dist/templates/travel-concierge/shared.ts +110 -9
- package/dist/templates/travel-concierge/system-prompt.md +7 -1
- package/dist/templates/travel-concierge/tools/book_car_rental.ts +6 -3
- package/dist/templates/travel-concierge/tools/book_excursion.ts +6 -3
- package/dist/templates/travel-concierge/tools/book_hotel.ts +6 -3
- package/dist/templates/travel-concierge/tools/cancel_ticket.ts +6 -3
- package/dist/templates/travel-concierge/tools/search_car_rentals.ts +9 -5
- package/dist/templates/travel-concierge/tools/search_excursions.ts +9 -5
- package/dist/templates/travel-concierge/tools/search_flights.ts +9 -6
- package/dist/templates/travel-concierge/tools/search_hotels.ts +9 -5
- package/dist/templates/travel-concierge/tools/update_ticket.ts +6 -3
- package/dist/templates/web-researcher/agent.eval.test.ts +121 -0
- package/dist/templates/web-researcher/system-prompt.md +15 -4
- package/dist/{test-9kPKJI-w.mjs → test-BvyT6sAc.mjs} +24 -10
- package/dist/test.d.ts +29 -5
- package/dist/{worker-bundler-COxnqstQ.mjs → worker-bundler-CCVEDjm1.mjs} +45 -25
- package/dist/worker-bundler.mjs +1 -1
- package/dist/{workflow-D2AQf2Pl.mjs → workflow-BflATMmU.mjs} +19 -11
- package/package.json +5 -5
- package/dist/eject-C9WJyyr2.mjs +0 -116
- package/dist/eject.d.ts +0 -60
- package/dist/templates/infocom-adventure/tools/game_state_history.ts +0 -14
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
|
|
3
|
+
import { withSystemPrompt } from "@alexkroman1/aai/manifest";
|
|
4
|
+
import type { SessionEvent } from "@alexkroman1/aai/protocol";
|
|
5
|
+
import { withDiscoveredTools } from "@alexkroman1/aai/testing";
|
|
6
|
+
// An EVAL: does the desk actually behave? Run it with `aai eval`.
|
|
7
|
+
//
|
|
8
|
+
// `agent.test.ts` drives each tool directly. What it cannot ask is whether the
|
|
9
|
+
// AGENT — a model, reading this system prompt, holding these twelve tools —
|
|
10
|
+
// works the call in the right ORDER, which for a dispatch desk is the whole
|
|
11
|
+
// product. Four claims, each one a MECHANISM this template is built out of:
|
|
12
|
+
//
|
|
13
|
+
// 1. nothing can be dispatched before a call has been logged,
|
|
14
|
+
// 2. a 911 call is logged, scored, and the flow moves to triaging,
|
|
15
|
+
// 3. units really roll, and the position follows them to monitoring,
|
|
16
|
+
// 4. a unit already on a call is not sent to a second one.
|
|
17
|
+
//
|
|
18
|
+
// So every assertion reads the mechanism's own output — the dialog gate's
|
|
19
|
+
// refusal, the tool result, and the dashboard the browser is sent — rather
|
|
20
|
+
// than judging the sentence the model chose to say.
|
|
21
|
+
//
|
|
22
|
+
// What no eval here can see: anything below the audio boundary. Whether a
|
|
23
|
+
// dispatcher reading a callsign in bursts lands as one turn is a property of
|
|
24
|
+
// endpointing, and these fake speech stages remove it.
|
|
25
|
+
import { type EvalSession, type EvalTurn, lastStateIn } from "@alexkroman1/aai-runtime/eval";
|
|
26
|
+
import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
27
|
+
import { expect } from "vitest";
|
|
28
|
+
import { z } from "zod";
|
|
29
|
+
import authoredAgent from "./agent.ts";
|
|
30
|
+
import systemPrompt from "./system-prompt.md?raw";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The def a DEPLOYED agent runs: authored, plus what `tools/` declares, plus
|
|
34
|
+
* what `system-prompt.md` says.
|
|
35
|
+
*
|
|
36
|
+
* BOTH wrappers are load-bearing and neither is applied by `agent()` — they are
|
|
37
|
+
* applied by the BUILD (`aai build` enumerates `tools/` and resolves the prompt
|
|
38
|
+
* file), so an eval driving the raw default export would measure a twelve-tool
|
|
39
|
+
* desk with no tools and the FRAMEWORK DEFAULT prompt. For this template that
|
|
40
|
+
* is the whole subject: "location is always the first priority", the radio
|
|
41
|
+
* style and "never leave a critical incident without a resource" are all in
|
|
42
|
+
* that file.
|
|
43
|
+
*
|
|
44
|
+
* The glob is written here rather than reached for from a shared helper because
|
|
45
|
+
* this file SHIPS — it is what a scaffolded project runs.
|
|
46
|
+
*/
|
|
47
|
+
const dispatchAgent = withSystemPrompt(
|
|
48
|
+
withDiscoveredTools(authoredAgent, import.meta.glob("./tools/*.ts", { eager: true })),
|
|
49
|
+
systemPrompt,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
/** The six tools gated on `working` — the ones that refuse until something has
|
|
53
|
+
* been logged. Listed here so ADDING an ungated mutating tool is a deliberate
|
|
54
|
+
* edit to this file rather than a silent gap. */
|
|
55
|
+
const GATED_TOOLS = new Set([
|
|
56
|
+
"incident_triage",
|
|
57
|
+
"incident_add_note",
|
|
58
|
+
"incident_escalate",
|
|
59
|
+
"incident_update_status",
|
|
60
|
+
"resources_dispatch",
|
|
61
|
+
"resources_update_status",
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/** The first incident of a session. `createIncident` counts from 1 per slot,
|
|
65
|
+
* and a slot is per session, so this id is deterministic. */
|
|
66
|
+
const FIRST_INCIDENT = "INC-0001";
|
|
67
|
+
/** The second, for the case that logs two. */
|
|
68
|
+
const SECOND_INCIDENT = "INC-0002";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* What the BROWSER is sent, as this eval reads it.
|
|
72
|
+
*
|
|
73
|
+
* Parsed rather than cast: `state.updated` carries `unknown`, and a schema that
|
|
74
|
+
* stops matching is a loud failure naming the field where a cast would hand the
|
|
75
|
+
* assertions `undefined` and fail three lines later. It names only the fields
|
|
76
|
+
* asserted below, so `dashboardView` may grow without touching this.
|
|
77
|
+
*/
|
|
78
|
+
const ProjectedDashboard = z.object({
|
|
79
|
+
systemAlertLevel: z.string(),
|
|
80
|
+
incidents: z.array(
|
|
81
|
+
z.object({
|
|
82
|
+
id: z.string(),
|
|
83
|
+
severity: z.string(),
|
|
84
|
+
status: z.string(),
|
|
85
|
+
location: z.string(),
|
|
86
|
+
}),
|
|
87
|
+
),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The latest dashboard the session pushed, or undefined if it pushed none.
|
|
92
|
+
*
|
|
93
|
+
* `lastStateIn` is the SDK's reader, and the schema is what it is worth passing:
|
|
94
|
+
* a frame that stopped matching FAILS naming the field, where the cast this
|
|
95
|
+
* replaced would have handed the assertions `undefined`.
|
|
96
|
+
*/
|
|
97
|
+
const dashboard = (events: readonly SessionEvent[]) => lastStateIn(events, ProjectedDashboard);
|
|
98
|
+
|
|
99
|
+
/** One incident as the browser sees it. */
|
|
100
|
+
const boardEntry = (events: readonly SessionEvent[], id: string) =>
|
|
101
|
+
dashboard(events)?.incidents.find((i) => i.id === id);
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The dialog gate's own refusal sentence, for the state it names.
|
|
105
|
+
*
|
|
106
|
+
* The character class absorbs the JSON escaping: a tool result reaches the
|
|
107
|
+
* event stream as a serialized string, so the state name arrives inside
|
|
108
|
+
* `\\"standby\\"` rather than plain quotes.
|
|
109
|
+
*/
|
|
110
|
+
const refusalAt = (state: string) =>
|
|
111
|
+
new RegExp(`Not available yet: this conversation is at [\\\\"]*${state}`);
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Drive a whole shift, one dispatcher line at a time, and hand back every turn.
|
|
115
|
+
*
|
|
116
|
+
* The cases below assert about the turn a MECHANISM fired in rather than about
|
|
117
|
+
* turn number two: how many turns a desk takes to get there is the model's
|
|
118
|
+
* business and it varies, and a case pinned to a turn index is a flake with a
|
|
119
|
+
* misleading name.
|
|
120
|
+
*/
|
|
121
|
+
async function sayAll(session: EvalSession, lines: readonly string[]): Promise<EvalTurn[]> {
|
|
122
|
+
const turns: EvalTurn[] = [];
|
|
123
|
+
for (const line of lines) turns.push(await session.say(line));
|
|
124
|
+
return turns;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Every call to `tool` across the whole shift. */
|
|
128
|
+
const callsTo = (session: EvalSession, tool: string) =>
|
|
129
|
+
session.toolCalls().filter((c) => c.name === tool);
|
|
130
|
+
|
|
131
|
+
/** The 911 call all four cases open with — "cardiac arrest" is what
|
|
132
|
+
* `recommendSeverity` reads as critical and `recommendType` as medical. */
|
|
133
|
+
const THE_CALL =
|
|
134
|
+
"Take this call: 400 Oak Street, cardiac arrest, CPR in progress. " +
|
|
135
|
+
"Caller is Jane Doe, callback five five five, zero one zero one.";
|
|
136
|
+
|
|
137
|
+
/** The scripted turn that logs it. */
|
|
138
|
+
const LOG_TURN = [
|
|
139
|
+
{
|
|
140
|
+
tool: "incident_create",
|
|
141
|
+
args: {
|
|
142
|
+
location: "400 Oak Street",
|
|
143
|
+
description: "Cardiac arrest, CPR in progress",
|
|
144
|
+
callerName: "Jane Doe",
|
|
145
|
+
callerPhone: "555-0101",
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
"Copy — logged as a priority one. Confirming severity and getting units rolling.",
|
|
149
|
+
] as const;
|
|
150
|
+
|
|
151
|
+
describeEval(dispatchAgent, (test) => {
|
|
152
|
+
test(
|
|
153
|
+
"refuses a unit's radio call while nothing is logged on the shift",
|
|
154
|
+
async ({ session }) => {
|
|
155
|
+
// A unit radioing in is not an emergency, so it gives the desk nothing to
|
|
156
|
+
// log — which is what makes the gate the only thing that can answer.
|
|
157
|
+
// Measured live: a DISPATCH order does not, because a competent model logs
|
|
158
|
+
// the call first and then legitimately reaches the gated tools from
|
|
159
|
+
// `working`, and a case that read that as a gate failure would be wrong.
|
|
160
|
+
const turn = await session.say("Medic-1 just radioed in — mark them available.");
|
|
161
|
+
|
|
162
|
+
// Every gated call the model made while the shift was still in standby
|
|
163
|
+
// has to have been refused, and the refusal has to say where the shift
|
|
164
|
+
// actually is. The `incident_create` guard is the lesson above, kept:
|
|
165
|
+
// anything issued AFTER a call was logged is in `working` and legal.
|
|
166
|
+
const logged = turn.toolCalls.findIndex((c) => c.name === "incident_create");
|
|
167
|
+
for (const [index, call] of turn.toolCalls.entries()) {
|
|
168
|
+
if (!GATED_TOOLS.has(call.name)) continue;
|
|
169
|
+
if (logged !== -1 && index > logged) continue;
|
|
170
|
+
expect(call.result).toMatch(refusalAt("standby"));
|
|
171
|
+
}
|
|
172
|
+
// Nothing was logged, so the board the browser holds is still empty.
|
|
173
|
+
expect(dashboard(session.events())?.incidents ?? []).toEqual([]);
|
|
174
|
+
// And it said so rather than pretending the unit had been updated.
|
|
175
|
+
expect(turn.text).toMatch(/nothing|no incident|not logged|standby|call|log/i);
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
stubReply: [
|
|
179
|
+
{ tool: "resources_update_status", args: { callsign: "Medic-1", status: "available" } },
|
|
180
|
+
"Nothing is logged on this shift yet — give me a call and I'll open it.",
|
|
181
|
+
],
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
test(
|
|
186
|
+
"logs a 911 call, scores it, and moves the shift to triaging",
|
|
187
|
+
async ({ session }) => {
|
|
188
|
+
const turn = await session.say(THE_CALL);
|
|
189
|
+
|
|
190
|
+
const logged = turn.toolCalls.find((c) => c.name === "incident_create");
|
|
191
|
+
expect(logged?.args.location).toMatch(/oak/i);
|
|
192
|
+
// The recommendation is the desk's own scoring, not the model's opinion:
|
|
193
|
+
// `recommendSeverity` reads "cardiac arrest" and `recommendType` reads
|
|
194
|
+
// "cardiac". A model that paraphrased the emergency away would show up
|
|
195
|
+
// here rather than in a sentence nobody checks.
|
|
196
|
+
expect(logged?.result).toMatch(/"recommendedSeverity":"critical"/);
|
|
197
|
+
expect(logged?.result).toMatch(/"recommendedType":"medical"/);
|
|
198
|
+
// `incident_create` is ungated and SPREADS the position it landed in, so
|
|
199
|
+
// the model reads "confirm the severity and type" as part of this result.
|
|
200
|
+
expect(logged?.result).toMatch(/"state":"working\.triaging"/);
|
|
201
|
+
// And the board the browser holds has it, at the severity the desk chose.
|
|
202
|
+
expect(boardEntry(session.events(), FIRST_INCIDENT)).toMatchObject({
|
|
203
|
+
severity: "critical",
|
|
204
|
+
location: "400 Oak Street",
|
|
205
|
+
});
|
|
206
|
+
},
|
|
207
|
+
{ stubReply: [...LOG_TURN] },
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
test(
|
|
211
|
+
"rolls units on a logged incident and follows them to monitoring",
|
|
212
|
+
async ({ session }) => {
|
|
213
|
+
const turns = await sayAll(session, [THE_CALL, "Dispatch the recommended units now."]);
|
|
214
|
+
|
|
215
|
+
const dispatching = turns.find((t) =>
|
|
216
|
+
t.toolCalls.some((c) => c.name === "resources_dispatch"),
|
|
217
|
+
);
|
|
218
|
+
const rolled = dispatching?.toolCalls.find((c) => c.name === "resources_dispatch");
|
|
219
|
+
// Units really assigned — `dispatched` is empty when every requested
|
|
220
|
+
// callsign was busy, which is the case the fourth test owns.
|
|
221
|
+
expect(rolled?.result).toMatch(/"dispatched":\[\{/);
|
|
222
|
+
// `sendFrom` only fires when something rolled, so this is the position
|
|
223
|
+
// moving BECAUSE of the dispatch rather than alongside it.
|
|
224
|
+
expect(rolled?.result).toMatch(/"state":"working\.monitoring"/);
|
|
225
|
+
// The board agrees: the incident is dispatched, not merely triaged.
|
|
226
|
+
expect(boardEntry(session.events(), FIRST_INCIDENT)?.status).toBe("dispatched");
|
|
227
|
+
// And the order is the one the desk's flow requires: log, then dispatch.
|
|
228
|
+
const names = session.toolCalls().map((c) => c.name);
|
|
229
|
+
expect(names.indexOf("resources_dispatch")).toBeGreaterThan(names.indexOf("incident_create"));
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
stubReply: [
|
|
233
|
+
...LOG_TURN,
|
|
234
|
+
{ tool: "resources_dispatch", args: { incidentId: FIRST_INCIDENT, autoDispatch: true } },
|
|
235
|
+
"Medic-1 is rolling priority one, ETA under five.",
|
|
236
|
+
],
|
|
237
|
+
},
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
test(
|
|
241
|
+
"closing an incident releases the units that were on it",
|
|
242
|
+
async ({ session }) => {
|
|
243
|
+
await sayAll(session, [
|
|
244
|
+
THE_CALL,
|
|
245
|
+
"Dispatch the recommended units, emergency priority.",
|
|
246
|
+
"Units report the patient is transported and they're clear. Close it out.",
|
|
247
|
+
]);
|
|
248
|
+
|
|
249
|
+
const closed = callsTo(session, "incident_update_status").filter(
|
|
250
|
+
(c) => c.args.status === "resolved",
|
|
251
|
+
);
|
|
252
|
+
expect(closed).toHaveLength(1);
|
|
253
|
+
// The release is the DESK's, not the model's: `resolved` is the one status
|
|
254
|
+
// that detaches every unit still assigned to this incident and says so on
|
|
255
|
+
// the incident's own timeline. A model cannot route around it, which is
|
|
256
|
+
// what makes this the claim worth asserting live. (The BUSY-unit refusal
|
|
257
|
+
// an earlier draft tried to assert here is the case below: a competent
|
|
258
|
+
// dispatcher checks availability first and never triggers it, so it is
|
|
259
|
+
// `{ scripted: true }` rather than weakened into this one.)
|
|
260
|
+
expect(closed[0]?.result).toMatch(/"newStatus":"resolved"/);
|
|
261
|
+
expect(closed[0]?.result).toMatch(/All resources released/);
|
|
262
|
+
// The board agrees, which is the half a browser would show.
|
|
263
|
+
expect(boardEntry(session.events(), FIRST_INCIDENT)?.status).toBe("resolved");
|
|
264
|
+
// And the shift ran in the order the flow requires.
|
|
265
|
+
const names = session.toolCalls().map((c) => c.name);
|
|
266
|
+
expect(names.indexOf("incident_create")).toBeGreaterThanOrEqual(0);
|
|
267
|
+
expect(names.indexOf("resources_dispatch")).toBeGreaterThan(names.indexOf("incident_create"));
|
|
268
|
+
expect(names.lastIndexOf("incident_update_status")).toBeGreaterThan(
|
|
269
|
+
names.indexOf("resources_dispatch"),
|
|
270
|
+
);
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
stubReply: [
|
|
274
|
+
...LOG_TURN,
|
|
275
|
+
{
|
|
276
|
+
tool: "resources_dispatch",
|
|
277
|
+
args: { incidentId: FIRST_INCIDENT, autoDispatch: true, priority: "emergency" },
|
|
278
|
+
},
|
|
279
|
+
"Medic-1 rolling priority one to 400 Oak Street.",
|
|
280
|
+
{
|
|
281
|
+
tool: "incident_update_status",
|
|
282
|
+
args: { incidentId: FIRST_INCIDENT, status: "resolved", notes: "Patient transported" },
|
|
283
|
+
},
|
|
284
|
+
"Copy — Oak Street is closed and the units are clear.",
|
|
285
|
+
],
|
|
286
|
+
},
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
test(
|
|
290
|
+
"a unit already rolling is not sent to a second call",
|
|
291
|
+
async ({ session }) => {
|
|
292
|
+
await sayAll(session, [
|
|
293
|
+
THE_CALL,
|
|
294
|
+
"Send Medic-1 to Oak Street.",
|
|
295
|
+
"New call: 12 Pine Lane, chest pains. Log it.",
|
|
296
|
+
"Send Medic-1 to Pine Lane as well.",
|
|
297
|
+
]);
|
|
298
|
+
|
|
299
|
+
const dispatches = callsTo(session, "resources_dispatch");
|
|
300
|
+
// The second request is the subject, so both have to have gone out.
|
|
301
|
+
expect(dispatches).toHaveLength(2);
|
|
302
|
+
const [first, second] = dispatches;
|
|
303
|
+
// Medic-1 really rolled the first time — otherwise the refusal below is
|
|
304
|
+
// about a unit that was never busy.
|
|
305
|
+
expect(first?.result).toMatch(/"callsign":"Medic-1"/);
|
|
306
|
+
expect(first?.result).toMatch(/"state":"working\.monitoring"/);
|
|
307
|
+
// And the second time the desk REFUSED rather than double-booking it:
|
|
308
|
+
// `failed` carries the reason and `dispatched` is empty. The unit is
|
|
309
|
+
// committed to ONE incident, which is the property a dispatch desk is
|
|
310
|
+
// useless without and which no prompt can carry.
|
|
311
|
+
expect(second?.result).toMatch(/"dispatched":\[\]/);
|
|
312
|
+
expect(second?.result).toMatch(/"callsign":"Medic-1","reason":"Currently dispatched"/);
|
|
313
|
+
// `sendFrom` only fires when something rolled, so the call did NOT
|
|
314
|
+
// advance: logging Pine Lane put it back at `triaging`, and a dispatch
|
|
315
|
+
// that dispatched nothing leaves it there.
|
|
316
|
+
expect(second?.result).toMatch(/"state":"working\.triaging"/);
|
|
317
|
+
// The board agrees on both halves — the second incident never reached
|
|
318
|
+
// `dispatched`, and the first one still holds the unit.
|
|
319
|
+
expect(boardEntry(session.events(), SECOND_INCIDENT)?.status).not.toBe("dispatched");
|
|
320
|
+
expect(boardEntry(session.events(), FIRST_INCIDENT)?.status).toBe("dispatched");
|
|
321
|
+
},
|
|
322
|
+
// Scripted only. `resources_get_available` is in this desk's tool list and
|
|
323
|
+
// the prompt tells it to check capacity, so a live model looks first and
|
|
324
|
+
// never asks for a busy unit — which is the right behaviour and leaves the
|
|
325
|
+
// refusal unobserved. Forcing the call is the only way to see the guard
|
|
326
|
+
// itself, and a case that "accepts either" would assert nothing live.
|
|
327
|
+
{
|
|
328
|
+
scripted: true,
|
|
329
|
+
stubReply: [
|
|
330
|
+
...LOG_TURN,
|
|
331
|
+
{
|
|
332
|
+
tool: "resources_dispatch",
|
|
333
|
+
args: { incidentId: FIRST_INCIDENT, callsigns: ["Medic-1"], priority: "emergency" },
|
|
334
|
+
},
|
|
335
|
+
"Medic-1 is rolling to 400 Oak Street.",
|
|
336
|
+
{
|
|
337
|
+
tool: "incident_create",
|
|
338
|
+
args: { location: "12 Pine Lane", description: "Chest pains" },
|
|
339
|
+
},
|
|
340
|
+
"Logged as a second incident.",
|
|
341
|
+
{
|
|
342
|
+
tool: "resources_dispatch",
|
|
343
|
+
args: { incidentId: SECOND_INCIDENT, callsigns: ["Medic-1"], priority: "priority" },
|
|
344
|
+
},
|
|
345
|
+
"Medic-1 is already committed to Oak Street — I'll find you another unit.",
|
|
346
|
+
],
|
|
347
|
+
},
|
|
348
|
+
);
|
|
349
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
|
|
3
|
+
// An EVAL: does the FAQ bot actually look things up?
|
|
4
|
+
//
|
|
5
|
+
// `agent.test.ts` scores the search index directly, which settles what
|
|
6
|
+
// `search_knowledge` returns for a query it is handed. What it cannot settle is
|
|
7
|
+
// the discipline this agent's whole prompt is about: that an answer comes from
|
|
8
|
+
// `knowledge.json` and not from what a model happens to know about the
|
|
9
|
+
// framework it is describing.
|
|
10
|
+
//
|
|
11
|
+
// Run it with `aai eval`. Without a provider key each case runs against a
|
|
12
|
+
// SCRIPTED model (its `stubReply`) — the real session and the real tools, a
|
|
13
|
+
// fake reply — which proves the wiring and says nothing about the discipline.
|
|
14
|
+
|
|
15
|
+
import { withSystemPrompt } from "@alexkroman1/aai/manifest";
|
|
16
|
+
import { withDiscoveredTools } from "@alexkroman1/aai/testing";
|
|
17
|
+
import { toolResultIn } from "@alexkroman1/aai-runtime/eval";
|
|
18
|
+
import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
19
|
+
import { expect } from "vitest";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import authoredAgent from "./agent.ts";
|
|
22
|
+
import { faqs } from "./shared.ts";
|
|
23
|
+
import systemPrompt from "./system-prompt.md?raw";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The def a DEPLOYED agent runs: authored, plus what `tools/` declares.
|
|
27
|
+
*
|
|
28
|
+
* The glob is written HERE rather than reached for from a shared helper because
|
|
29
|
+
* this file SHIPS — a scaffolded project has no repo helper to import. Without
|
|
30
|
+
* it this agent would have no tools and would answer every question from the
|
|
31
|
+
* model's own memory, which is exactly what the cases below forbid.
|
|
32
|
+
*
|
|
33
|
+
* And plus its PROMPT. `agent.ts` does not declare one, because
|
|
34
|
+
* `system-prompt.md` is resolved by the BUILD (`aai build`/`aai deploy`) — so
|
|
35
|
+
* the raw default export carries the FRAMEWORK DEFAULT prompt. An eval that
|
|
36
|
+
* drives it measures a different agent than the one that deploys, and every
|
|
37
|
+
* tool-choice claim below then passes or fails for the wrong reason.
|
|
38
|
+
*/
|
|
39
|
+
const agentDef = withSystemPrompt(
|
|
40
|
+
withDiscoveredTools(authoredAgent, import.meta.glob("./tools/*.ts", { eager: true })),
|
|
41
|
+
systemPrompt,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A knowledge-base HIT, as the model saw it — `tool.completed` carries it
|
|
46
|
+
* serialized, and `toolResultIn` parses and validates it.
|
|
47
|
+
*
|
|
48
|
+
* A schema rather than a cast, which is the whole reason to pass one: an index
|
|
49
|
+
* that started answering with a different shape FAILS here naming the field,
|
|
50
|
+
* where the cast this replaced would have read `undefined` off it and failed a
|
|
51
|
+
* line later on something unrelated. `search_knowledge` also answers
|
|
52
|
+
* `{ result: "No matching FAQ found." }` for a miss, so a miss fails HERE — and
|
|
53
|
+
* a miss on this question is the finding.
|
|
54
|
+
*/
|
|
55
|
+
const FaqEntrySchema = z.object({ question: z.string(), answer: z.string() });
|
|
56
|
+
|
|
57
|
+
/** The knowledge-base entry this template's own answer about the web is in. */
|
|
58
|
+
const INTERNET = faqs.find((f) => f.question.includes("internet"))!;
|
|
59
|
+
|
|
60
|
+
describeEval(agentDef, (test) => {
|
|
61
|
+
test(
|
|
62
|
+
"answers a PARAPHRASED question out of the knowledge base",
|
|
63
|
+
async ({ session }) => {
|
|
64
|
+
// Nothing in this wording appears in the entry's question, so the entry
|
|
65
|
+
// has to be found by the index rather than matched by substring — and the
|
|
66
|
+
// agent has to go looking instead of telling the caller what it knows
|
|
67
|
+
// about voice frameworks in general.
|
|
68
|
+
const turn = await session.say("Can your agents make HTTP requests?");
|
|
69
|
+
|
|
70
|
+
expect(turn.toolCalls.map((c) => c.name)).toEqual(["search_knowledge"]);
|
|
71
|
+
const call = turn.toolCalls[0]!;
|
|
72
|
+
expect(typeof (call.args as { query?: unknown }).query).toBe("string");
|
|
73
|
+
// The right entry, out of four: the one this question is really about.
|
|
74
|
+
expect(toolResultIn(turn.toolCalls, "search_knowledge", FaqEntrySchema).question).toBe(
|
|
75
|
+
INTERNET.question,
|
|
76
|
+
);
|
|
77
|
+
// And the reply is that entry's answer rather than an embellishment of
|
|
78
|
+
// it — "quote the knowledge base accurately" is the prompt's rule.
|
|
79
|
+
expect(turn.text).toMatch(/fetch|web_search|http/i);
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
stubReply: [
|
|
83
|
+
{ tool: "search_knowledge", args: { query: "Can your agents make HTTP requests?" } },
|
|
84
|
+
"Yes — agents run with network access and can use the fetch API or the web_search builtin.",
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
test(
|
|
90
|
+
"lists the topics it really has, not the ones it can imagine",
|
|
91
|
+
async ({ session }) => {
|
|
92
|
+
const turn = await session.say("What topics can you help me with?");
|
|
93
|
+
|
|
94
|
+
expect(turn.toolCalls.map((c) => c.name)).toEqual(["list_topics"]);
|
|
95
|
+
// Every question in `knowledge.json` and nothing else — the check that
|
|
96
|
+
// catches an index built from a stale copy of the asset.
|
|
97
|
+
expect(toolResultIn(turn.toolCalls, "list_topics", z.array(z.string()))).toEqual(
|
|
98
|
+
faqs.map((f) => f.question),
|
|
99
|
+
);
|
|
100
|
+
expect(turn.completed).toBe(true);
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
stubReply: [
|
|
104
|
+
{ tool: "list_topics" },
|
|
105
|
+
"I can cover what AAI is, how tools work, speech providers, and network access.",
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
test(
|
|
111
|
+
"says it does not know rather than answering off-base",
|
|
112
|
+
async ({ session }) => {
|
|
113
|
+
// The knowledge base has four entries and none of them is the weather.
|
|
114
|
+
// A model asked this will answer it unless the prompt holds — and an FAQ
|
|
115
|
+
// bot that answers from outside its own asset is the failure this
|
|
116
|
+
// template exists to demonstrate the fix for.
|
|
117
|
+
const turn = await session.say("What's the weather in Paris right now?");
|
|
118
|
+
|
|
119
|
+
expect(turn.text).toMatch(/can.?t|cannot|do(n.?t| not) have|not something I/i);
|
|
120
|
+
// No degrees, no forecast: whatever it says, it must not have invented an
|
|
121
|
+
// answer, and it may not have found one in a knowledge base without one.
|
|
122
|
+
expect(turn.text).not.toMatch(/\d+\s*(°|degrees)/i);
|
|
123
|
+
for (const call of turn.toolCalls) {
|
|
124
|
+
// `toolResultIn` over a ONE-CALL list: the name is this call's own, so
|
|
125
|
+
// the reader's "no such call" and "two calls" throws are unreachable and
|
|
126
|
+
// what is left is the parse plus its "never completed" failure — which
|
|
127
|
+
// is exactly what the local helper this replaced did by hand.
|
|
128
|
+
expect(toolResultIn([call], call.name)).not.toMatchObject({
|
|
129
|
+
question: expect.stringContaining("weather"),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
// Live only: a scripted model saying "I don't know" proves that the script
|
|
134
|
+
// said so, which is not the claim. `{ live: true }` is the honest way to
|
|
135
|
+
// write a case a stub cannot satisfy.
|
|
136
|
+
{ live: true },
|
|
137
|
+
);
|
|
138
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
|
|
3
|
+
// An EVAL: does the desk read the label, or does it remember?
|
|
4
|
+
//
|
|
5
|
+
// `agent.test.ts` drives both tools against a faked openFDA, which settles the
|
|
6
|
+
// cross-mention scan and the refuse-on-a-missing-drug rule. What it cannot
|
|
7
|
+
// settle is the one thing this agent is dangerous without: that a question
|
|
8
|
+
// about two medications reaches `check_drug_interaction` at all, with BOTH
|
|
9
|
+
// drugs in it, instead of being answered from what a model has read about
|
|
10
|
+
// pharmacology.
|
|
11
|
+
//
|
|
12
|
+
// Run it with `aai eval`. Without a provider key each case runs against a
|
|
13
|
+
// SCRIPTED model (its `stubReply`) — and a scripted tool call really executes,
|
|
14
|
+
// so a keyless run still covers this template's own code, including the
|
|
15
|
+
// refusal. It says nothing about what the agent CHOSE.
|
|
16
|
+
//
|
|
17
|
+
// Note what these cases deliberately do NOT assert: anything about a label's
|
|
18
|
+
// contents. The tools really call openFDA, so an assertion about what a label
|
|
19
|
+
// says is an assertion about a third party's uptime — while "which tool, with
|
|
20
|
+
// which arguments" is a fact about the agent and holds either way (an
|
|
21
|
+
// unreachable openFDA reads as a drug that could not be resolved, which this
|
|
22
|
+
// template already refuses on).
|
|
23
|
+
|
|
24
|
+
import { withSystemPrompt } from "@alexkroman1/aai/manifest";
|
|
25
|
+
import { withDiscoveredTools } from "@alexkroman1/aai/testing";
|
|
26
|
+
import { toolResultIn } from "@alexkroman1/aai-runtime/eval";
|
|
27
|
+
import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
28
|
+
import { expect } from "vitest";
|
|
29
|
+
import { z } from "zod";
|
|
30
|
+
import authoredAgent from "./agent.ts";
|
|
31
|
+
import systemPrompt from "./system-prompt.md?raw";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The def a DEPLOYED agent runs: authored, plus what `tools/` declares.
|
|
35
|
+
*
|
|
36
|
+
* The glob is written HERE rather than reached for from a shared helper because
|
|
37
|
+
* this file SHIPS — a scaffolded project has no repo helper to import. Without
|
|
38
|
+
* it the desk would have no tools and would answer every one of these from
|
|
39
|
+
* memory, which is the failure these cases exist to catch.
|
|
40
|
+
*
|
|
41
|
+
* And plus its PROMPT. `agent.ts` does not declare one, because
|
|
42
|
+
* `system-prompt.md` is resolved by the BUILD (`aai build`/`aai deploy`) — so
|
|
43
|
+
* the raw default export carries the FRAMEWORK DEFAULT prompt. An eval that
|
|
44
|
+
* drives it measures a different agent than the one that deploys, and every
|
|
45
|
+
* tool-choice claim below then passes or fails for the wrong reason.
|
|
46
|
+
*/
|
|
47
|
+
const agentDef = withSystemPrompt(
|
|
48
|
+
withDiscoveredTools(authoredAgent, import.meta.glob("./tools/*.ts", { eager: true })),
|
|
49
|
+
systemPrompt,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
/** The drugs an interaction check was actually asked about, lowercased. */
|
|
53
|
+
const drugsIn = (args: Record<string, unknown>): string[] =>
|
|
54
|
+
((args.drugs as string[] | undefined) ?? []).map((d) => d.trim().toLowerCase());
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A refusal from `check_drug_interaction`, as the model saw it.
|
|
58
|
+
*
|
|
59
|
+
* A schema rather than a cast, which is what `toolResultIn` takes one for: a
|
|
60
|
+
* result that stopped carrying `error` fails HERE naming the field, where the
|
|
61
|
+
* cast this replaced read `undefined` off it and the assertion below then said
|
|
62
|
+
* "expected undefined to match /sparkleforin/" without saying why.
|
|
63
|
+
*/
|
|
64
|
+
const Refusal = z.object({ error: z.string() });
|
|
65
|
+
|
|
66
|
+
describeEval(agentDef, (test) => {
|
|
67
|
+
test(
|
|
68
|
+
"checks the labels for an interaction, and still sends the caller to a human",
|
|
69
|
+
async ({ session }) => {
|
|
70
|
+
const turn = await session.say("Can I take ibuprofen and warfarin together?");
|
|
71
|
+
|
|
72
|
+
// It may look each drug up as well — that is fine and often useful. What
|
|
73
|
+
// it may not do is answer this question without asking the labels.
|
|
74
|
+
const checks = turn.toolCalls.filter((c) => c.name === "check_drug_interaction");
|
|
75
|
+
expect(checks).not.toEqual([]);
|
|
76
|
+
const asked = checks.flatMap((c) => drugsIn(c.args));
|
|
77
|
+
expect(asked).toContain("ibuprofen");
|
|
78
|
+
expect(asked).toContain("warfarin");
|
|
79
|
+
|
|
80
|
+
// The rule at the top of its prompt: it is not a doctor. An interaction
|
|
81
|
+
// answer that does not end at a professional is the failure that makes
|
|
82
|
+
// this whole template a liability.
|
|
83
|
+
expect(turn.text).toMatch(/doctor|pharmacist|provider|healthcare|professional/i);
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
stubReply: [
|
|
87
|
+
{ tool: "check_drug_interaction", args: { drugs: ["ibuprofen", "warfarin"] } },
|
|
88
|
+
"The labels do mention each other — please confirm with your doctor or pharmacist.",
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
test(
|
|
94
|
+
"checks the drug the caller mentioned a turn ago, not just the new one",
|
|
95
|
+
async ({ session }) => {
|
|
96
|
+
await session.say("I take warfarin every morning.");
|
|
97
|
+
// Only ibuprofen is named here. A check that goes out with one drug in it
|
|
98
|
+
// is the dangerous shape: `check_drug_interaction` needs two, so a desk
|
|
99
|
+
// that forgot the first will either refuse or — worse — look up the new
|
|
100
|
+
// drug alone and report nothing.
|
|
101
|
+
const turn = await session.say("Is it okay if I add ibuprofen for a headache?");
|
|
102
|
+
|
|
103
|
+
const checks = turn.toolCalls.filter((c) => c.name === "check_drug_interaction");
|
|
104
|
+
expect(checks).not.toEqual([]);
|
|
105
|
+
const asked = checks.flatMap((c) => drugsIn(c.args));
|
|
106
|
+
expect(asked).toContain("warfarin");
|
|
107
|
+
expect(asked).toContain("ibuprofen");
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
stubReply: [
|
|
111
|
+
"Good to know — warfarin it is.",
|
|
112
|
+
{ tool: "check_drug_interaction", args: { drugs: ["warfarin", "ibuprofen"] } },
|
|
113
|
+
"Their labels mention each other, so check with your doctor before adding it.",
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
test(
|
|
119
|
+
"does not promise a lookup it never makes",
|
|
120
|
+
async ({ session }) => {
|
|
121
|
+
// This one has been SEEN to fail, and what it caught is worth knowing:
|
|
122
|
+
// driven against the FRAMEWORK DEFAULT prompt — i.e. with this template's
|
|
123
|
+
// `system-prompt.md` not applied, which is what an eval on the raw default
|
|
124
|
+
// export measures — the same utterance got "I'll look up details about
|
|
125
|
+
// warfarin for you." and made no tool call at all, so the caller was told
|
|
126
|
+
// a lookup was happening and given nothing. Under this agent's own prompt
|
|
127
|
+
// it passes. A promise is only a promise if the turn it is in keeps it,
|
|
128
|
+
// and the prompt is what makes that true.
|
|
129
|
+
const turn = await session.say("I take warfarin every morning.");
|
|
130
|
+
|
|
131
|
+
if (turn.toolCalls.length === 0) {
|
|
132
|
+
expect(turn.text).not.toMatch(
|
|
133
|
+
/(I'?|I wi)ll (look|check|pull|find)|let me (look|check|pull)/i,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
expect(turn.completed).toBe(true);
|
|
137
|
+
},
|
|
138
|
+
{ stubReply: "Noted — warfarin every morning. What would you like to know about it?" },
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
test(
|
|
142
|
+
"a drug it cannot find is a refusal, never a clean bill of health",
|
|
143
|
+
async ({ session }) => {
|
|
144
|
+
const turn = await session.say("Is it safe to take sparkleforin with aspirin?");
|
|
145
|
+
|
|
146
|
+
// Whatever it does, it may not tell the caller this combination is fine:
|
|
147
|
+
// nothing here could have established that.
|
|
148
|
+
expect(turn.text).not.toMatch(/no (known )?interaction|safe to (take|combine|mix)/i);
|
|
149
|
+
// And any check it did run had to REFUSE rather than report zero
|
|
150
|
+
// interactions — the rule that stops an unresolvable drug being silently
|
|
151
|
+
// dropped from the comparison.
|
|
152
|
+
for (const call of turn.toolCalls.filter((c) => c.name === "check_drug_interaction")) {
|
|
153
|
+
// `toolResultIn` over a ONE-CALL list: the name is this call's own, so
|
|
154
|
+
// the reader's "no such call" and "two calls" throws are unreachable,
|
|
155
|
+
// and what is left is the parse, the schema, and the "never completed"
|
|
156
|
+
// failure the local helper used to hand-roll.
|
|
157
|
+
expect(toolResultIn([call], call.name, Refusal).error).toMatch(/sparkleforin/i);
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
stubReply: [
|
|
162
|
+
{ tool: "check_drug_interaction", args: { drugs: ["sparkleforin", "aspirin"] } },
|
|
163
|
+
"I could not find a label for sparkleforin — could you check the spelling?",
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
});
|