@alexkroman1/aai-cli 7.0.0 → 8.0.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-Me06wgeF.mjs} +2 -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-BvcctCn2.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,314 @@
|
|
|
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 this desk actually behave? Run it with `aai eval`.
|
|
7
|
+
//
|
|
8
|
+
// `agent.test.ts` drives each tool directly and asserts about its result;
|
|
9
|
+
// `registry.test.ts` asserts every tool's gate one at a time. Neither can say
|
|
10
|
+
// whether the AGENT — a model, reading this system prompt, holding these
|
|
11
|
+
// seventeen tools — puts them in the right ORDER. That is what this file is
|
|
12
|
+
// for, and the four things it asserts are the four this template's whole shape
|
|
13
|
+
// exists to guarantee:
|
|
14
|
+
//
|
|
15
|
+
// 1. nothing about an order is reachable before the caller is identified,
|
|
16
|
+
// 2. a change is STAGED and the store is untouched,
|
|
17
|
+
// 3. an explicit yes applies it, exactly once,
|
|
18
|
+
// 4. after a handoff the call is over and every tool refuses.
|
|
19
|
+
//
|
|
20
|
+
// Each one is a MECHANISM (a dialog gate, a plan/apply split, a terminal
|
|
21
|
+
// state), so each assertion reads the mechanism's own output — the SDK's
|
|
22
|
+
// refusal sentence, the tool result, and the projection the browser is sent —
|
|
23
|
+
// rather than judging the sentence the model chose to say.
|
|
24
|
+
//
|
|
25
|
+
// What no eval here can see: anything below the audio boundary. Whether a
|
|
26
|
+
// caller reading an order number in bursts lands as one turn is a property of
|
|
27
|
+
// endpointing, and these fake speech stages remove it.
|
|
28
|
+
import { type EvalSession, type EvalTurn, lastStateIn } from "@alexkroman1/aai-runtime/eval";
|
|
29
|
+
import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
30
|
+
import { expect } from "vitest";
|
|
31
|
+
import { z } from "zod";
|
|
32
|
+
import authoredAgent from "./agent.ts";
|
|
33
|
+
import systemPrompt from "./system-prompt.md?raw";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The def a DEPLOYED agent runs: authored, plus what `tools/` declares, plus
|
|
37
|
+
* what `system-prompt.md` says.
|
|
38
|
+
*
|
|
39
|
+
* BOTH wrappers are load-bearing and neither is applied by `agent()` — they are
|
|
40
|
+
* applied by the BUILD (`aai build` enumerates `tools/` and resolves the prompt
|
|
41
|
+
* file), so an eval driving the raw default export would measure a
|
|
42
|
+
* seventeen-tool desk with no tools and the FRAMEWORK DEFAULT prompt. For this
|
|
43
|
+
* template that is the whole subject: the authenticate-first discipline, the
|
|
44
|
+
* three-step readback and the one-customer-per-call rule are all in that file.
|
|
45
|
+
*
|
|
46
|
+
* The glob is written here rather than reached for from a shared helper because
|
|
47
|
+
* this file SHIPS — see `registry.test.ts`.
|
|
48
|
+
*/
|
|
49
|
+
const retailAgent = withSystemPrompt(
|
|
50
|
+
withDiscoveredTools(authoredAgent, import.meta.glob("./tools/*.ts", { eager: true })),
|
|
51
|
+
systemPrompt,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
/** Harper Brown: exactly ONE pending order, which is what makes "cancel my
|
|
55
|
+
* pending order" unambiguous — `resolveOrder` refuses a reference that matches
|
|
56
|
+
* two, so a persona with two pending orders would put this eval's subject
|
|
57
|
+
* (the confirmation gate) behind a disambiguation turn. */
|
|
58
|
+
const CALLER_EMAIL = "harper.brown3965@example.com";
|
|
59
|
+
const PENDING_ORDER = "#W2273069";
|
|
60
|
+
|
|
61
|
+
/** The six tools that legitimately run before the caller is identified — the
|
|
62
|
+
* ones declaring `when: BEFORE_TRANSFER`. Anything else must refuse. */
|
|
63
|
+
const PUBLIC_TOOLS = new Set([
|
|
64
|
+
"find_user_id_by_email",
|
|
65
|
+
"find_user_id_by_name_zip",
|
|
66
|
+
"get_product_details",
|
|
67
|
+
"get_item_details",
|
|
68
|
+
"list_all_product_types",
|
|
69
|
+
"transfer_to_human_agents",
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* What the BROWSER is sent, as this eval reads it.
|
|
74
|
+
*
|
|
75
|
+
* Parsed rather than cast: `state.updated` carries `unknown`, and a schema that
|
|
76
|
+
* stops matching is a loud failure naming the field, where a cast would hand
|
|
77
|
+
* the assertions `undefined` and fail three lines later. It names only the
|
|
78
|
+
* fields asserted below, so `storeView` may grow without touching this.
|
|
79
|
+
*/
|
|
80
|
+
const ProjectedStore = z.object({
|
|
81
|
+
customer: z.object({ userId: z.string() }).nullable(),
|
|
82
|
+
orders: z.array(z.object({ orderId: z.string(), status: z.string() })),
|
|
83
|
+
pending: z.object({ kind: z.string(), readBack: z.string() }).nullable(),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The latest projection the session pushed, or undefined if it pushed none.
|
|
88
|
+
*
|
|
89
|
+
* `lastStateIn` is the SDK's reader; the schema is what is worth passing to it,
|
|
90
|
+
* for the reason above — a frame that stopped matching fails naming the field.
|
|
91
|
+
*/
|
|
92
|
+
const projection = (events: readonly SessionEvent[]) => lastStateIn(events, ProjectedStore);
|
|
93
|
+
|
|
94
|
+
/** The status the projection carries for `orderId` — the one fact "did anything
|
|
95
|
+
* change?" turns on. `undefined` covers both "no projection yet" and "not this
|
|
96
|
+
* customer's order", which are the same claim here: it was not touched. */
|
|
97
|
+
function statusOf(events: readonly SessionEvent[], orderId: string): string | undefined {
|
|
98
|
+
return projection(events)?.orders.find((o) => o.orderId === orderId)?.status;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The dialog gate's own refusal sentence, for the state it names.
|
|
103
|
+
*
|
|
104
|
+
* The character class absorbs the JSON escaping: a tool result reaches the
|
|
105
|
+
* event stream as a serialized string, so the state name arrives inside
|
|
106
|
+
* `\\"identifying\\"` rather than plain quotes.
|
|
107
|
+
*/
|
|
108
|
+
const refusalAt = (state: string) =>
|
|
109
|
+
new RegExp(`Not available yet: this conversation is at [\\\\"]*${state}`);
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Drive a whole call, one caller line at a time, and hand back every turn.
|
|
113
|
+
*
|
|
114
|
+
* The cases below assert about the turn a MECHANISM fired in rather than about
|
|
115
|
+
* turn number two, because how many turns a desk takes to get there is the
|
|
116
|
+
* model's business and it really does vary: measured live, this agent reads the
|
|
117
|
+
* order back out of `get_order_details` and asks before it stages, so the
|
|
118
|
+
* staging call has landed in turn two, three and four across runs. A case
|
|
119
|
+
* pinned to a turn index is a flake with a 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
|
+
/** The turn a named tool was called in, if any. */
|
|
128
|
+
const turnCalling = (turns: readonly EvalTurn[], tool: string) =>
|
|
129
|
+
turns.find((t) => t.toolCalls.some((c) => c.name === tool));
|
|
130
|
+
|
|
131
|
+
/** One line the caller says to identify themselves, and the scripted tool call
|
|
132
|
+
* that answers it — the first turn of three of these four cases. */
|
|
133
|
+
const AUTH_TURN = [
|
|
134
|
+
{ tool: "find_user_id_by_email", args: { email: CALLER_EMAIL } },
|
|
135
|
+
"Thanks — I have your account here. What can I do for you?",
|
|
136
|
+
] as const;
|
|
137
|
+
|
|
138
|
+
/** The staged cancellation, as a scripted turn. */
|
|
139
|
+
const STAGE_TURN = [
|
|
140
|
+
{
|
|
141
|
+
tool: "cancel_pending_order",
|
|
142
|
+
args: { order_id: PENDING_ORDER, reason: "ordered by mistake" },
|
|
143
|
+
},
|
|
144
|
+
"So that would cancel your pending order and refund one thousand two hundred dollars " +
|
|
145
|
+
"and fifty-seven cents to your Visa. Is that right?",
|
|
146
|
+
] as const;
|
|
147
|
+
|
|
148
|
+
describeEval(retailAgent, (test) => {
|
|
149
|
+
test(
|
|
150
|
+
"will not touch an order before the caller is identified",
|
|
151
|
+
async ({ session }) => {
|
|
152
|
+
const turn = await session.say(
|
|
153
|
+
"Hi — cancel my pending order please, number W two two seven three zero six nine. " +
|
|
154
|
+
"I don't need it any more.",
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
// The claim is about the GATE, so it is made of every gated call the
|
|
158
|
+
// model chose to make: each one has to have been refused, and the
|
|
159
|
+
// refusal has to say where the call actually is. A model that asks for
|
|
160
|
+
// the email instead makes no gated call at all, which satisfies this
|
|
161
|
+
// vacuously and is the same right answer — the two remaining assertions
|
|
162
|
+
// are what stop the case being vacuous overall.
|
|
163
|
+
for (const call of turn.toolCalls) {
|
|
164
|
+
if (PUBLIC_TOOLS.has(call.name)) continue;
|
|
165
|
+
expect(call.result).toMatch(refusalAt("identifying"));
|
|
166
|
+
}
|
|
167
|
+
// Nothing was authenticated, so the browser has been sent no customer —
|
|
168
|
+
// which is also the projection's security claim.
|
|
169
|
+
expect(projection(session.events())?.customer ?? null).toBeNull();
|
|
170
|
+
// And it asked for the one thing it needs.
|
|
171
|
+
expect(turn.text).toMatch(/email|account|name|zip/i);
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
stubReply: [
|
|
175
|
+
{
|
|
176
|
+
tool: "cancel_pending_order",
|
|
177
|
+
args: { order_id: PENDING_ORDER, reason: "no longer needed" },
|
|
178
|
+
},
|
|
179
|
+
"Before I can look anything up I'll need to find your account — what's the email on it?",
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
test(
|
|
185
|
+
"stages a cancellation and changes nothing until the caller says yes",
|
|
186
|
+
async ({ session }) => {
|
|
187
|
+
// FIVE lines, because how many turns this desk takes to STAGE is its own
|
|
188
|
+
// business and measured live it varies by two: reading the order back out
|
|
189
|
+
// of `get_order_details` and asking "does that sound right?" is a whole
|
|
190
|
+
// turn, and whether it spends one is not something an eval should pin.
|
|
191
|
+
// Every assertion below is about the turn the staging landed in, so a
|
|
192
|
+
// later apply cannot affect any of them.
|
|
193
|
+
const turns = await sayAll(session, [
|
|
194
|
+
`My email is ${CALLER_EMAIL}.`,
|
|
195
|
+
"I'd like to cancel my pending order — I ordered it by mistake.",
|
|
196
|
+
"Yes, please go ahead and cancel it.",
|
|
197
|
+
"Yes — I'm sure. Cancel it.",
|
|
198
|
+
"Yes. Cancel it, please.",
|
|
199
|
+
]);
|
|
200
|
+
|
|
201
|
+
const staging = turnCalling(turns, "cancel_pending_order");
|
|
202
|
+
const staged = staging?.toolCalls.find((c) => c.name === "cancel_pending_order");
|
|
203
|
+
expect(staged?.result).toMatch(/NOTHING HAS CHANGED YET/);
|
|
204
|
+
// The gate is a POSITION, and this is it moving: the tool reported the
|
|
205
|
+
// state it landed in, which is the only state `confirm_change` is legal
|
|
206
|
+
// in and is reachable only by staging.
|
|
207
|
+
expect(staged?.result).toMatch(/serving\.awaitingConfirmation/);
|
|
208
|
+
// A change cannot be described and applied in the same turn. This is the
|
|
209
|
+
// property the prose in the system prompt could never have.
|
|
210
|
+
expect(staging?.toolCalls.map((c) => c.name)).not.toContain("confirm_change");
|
|
211
|
+
// And after the turn that staged it, the store really is untouched — read
|
|
212
|
+
// off the projection the BROWSER was sent in that same turn.
|
|
213
|
+
expect(statusOf(staging?.events ?? [], PENDING_ORDER)).toBe("pending");
|
|
214
|
+
expect(projection(staging?.events ?? [])?.pending?.kind).toBe("cancel_pending_order");
|
|
215
|
+
// Step 2 of the policy: read it back and ask.
|
|
216
|
+
expect(staging?.text).toMatch(/\?/);
|
|
217
|
+
},
|
|
218
|
+
{ stubReply: [...AUTH_TURN, ...STAGE_TURN, "Cancelling it now — one moment."] },
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
test(
|
|
222
|
+
"applies the staged change on an explicit yes, exactly once",
|
|
223
|
+
async ({ session }) => {
|
|
224
|
+
// Five lines, three of them a yes: which turn the desk applies the change
|
|
225
|
+
// in is its own business, and saying yes repeatedly is what makes
|
|
226
|
+
// "exactly once" below a claim about the MECHANISM rather than about the
|
|
227
|
+
// model's pacing.
|
|
228
|
+
await sayAll(session, [
|
|
229
|
+
`My email is ${CALLER_EMAIL}.`,
|
|
230
|
+
"Please cancel my pending order — I ordered it by mistake.",
|
|
231
|
+
"Yes, that's right, go ahead.",
|
|
232
|
+
"Yes — confirm it now, please.",
|
|
233
|
+
"Yes. Confirm it.",
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
const confirms = session.toolCalls().filter((c) => c.name === "confirm_change");
|
|
237
|
+
const applied = confirms.filter((c) => c.result?.includes('"status":"cancelled"'));
|
|
238
|
+
// ONE apply for the whole call, however many times the caller said yes.
|
|
239
|
+
// Measured live, a second yes really does produce a second
|
|
240
|
+
// `confirm_change` — and it is REFUSED, because applying cleared
|
|
241
|
+
// `pending` and sent `SETTLED`, and `awaitingConfirmation` is reachable
|
|
242
|
+
// only by staging something new. That is the gate doing the one job a
|
|
243
|
+
// prompt could not: a repeated yes cannot cancel an order twice.
|
|
244
|
+
expect(applied).toHaveLength(1);
|
|
245
|
+
for (const extra of confirms.filter((c) => c !== applied[0])) {
|
|
246
|
+
expect(extra.result).toMatch(/Not available yet/);
|
|
247
|
+
}
|
|
248
|
+
// And it came after the stage, never instead of it.
|
|
249
|
+
const names = session.toolCalls().map((c) => c.name);
|
|
250
|
+
expect(names.indexOf("cancel_pending_order")).toBeGreaterThanOrEqual(0);
|
|
251
|
+
expect(names.indexOf("confirm_change")).toBeGreaterThan(
|
|
252
|
+
names.indexOf("cancel_pending_order"),
|
|
253
|
+
);
|
|
254
|
+
// The store moved, and the staged change is gone with it.
|
|
255
|
+
expect(statusOf(session.events(), PENDING_ORDER)).toBe("cancelled");
|
|
256
|
+
expect(projection(session.events())?.pending).toBeNull();
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
stubReply: [
|
|
260
|
+
...AUTH_TURN,
|
|
261
|
+
...STAGE_TURN,
|
|
262
|
+
{ tool: "confirm_change", args: {} },
|
|
263
|
+
"That's cancelled, and the refund is on its way to your Visa.",
|
|
264
|
+
],
|
|
265
|
+
},
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
test(
|
|
269
|
+
"hands the call to a human and then refuses everything, including the order",
|
|
270
|
+
async ({ session }) => {
|
|
271
|
+
await session.say(`My email is ${CALLER_EMAIL}.`);
|
|
272
|
+
const handoff = await session.say(
|
|
273
|
+
"This isn't working for me — I want to speak to a real person.",
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const transfer = handoff.toolCalls.find((c) => c.name === "transfer_to_human_agents");
|
|
277
|
+
// Named first, and with a message: a live model that answers the request
|
|
278
|
+
// with a question instead of the tool leaves `transfer` undefined, and
|
|
279
|
+
// `.toMatch()` on it reports only "expected a string, got undefined" —
|
|
280
|
+
// which says nothing about what the desk actually did.
|
|
281
|
+
expect(
|
|
282
|
+
transfer,
|
|
283
|
+
`tools called: [${handoff.toolCalls.map((c) => c.name).join(", ")}]; said: ${handoff.text}`,
|
|
284
|
+
).toBeDefined();
|
|
285
|
+
// The terminal state is what makes "say nothing else after this" a
|
|
286
|
+
// property of the agent rather than a line in its prompt: `done` is the
|
|
287
|
+
// flow saying there is nowhere left to go.
|
|
288
|
+
expect(transfer?.result).toMatch(/"state":"transferred"/);
|
|
289
|
+
expect(transfer?.result).toMatch(/"done":true/);
|
|
290
|
+
|
|
291
|
+
const after = await session.say("Actually, before you go — just cancel my pending order.");
|
|
292
|
+
for (const call of after.toolCalls) {
|
|
293
|
+
expect(call.result).toMatch(refusalAt("transferred"));
|
|
294
|
+
}
|
|
295
|
+
// Which is the point: the order the caller asked about is untouched.
|
|
296
|
+
expect(statusOf(session.events(), PENDING_ORDER)).toBe("pending");
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
stubReply: [
|
|
300
|
+
...AUTH_TURN,
|
|
301
|
+
{
|
|
302
|
+
tool: "transfer_to_human_agents",
|
|
303
|
+
args: { summary: "Caller asked for a human agent." },
|
|
304
|
+
},
|
|
305
|
+
"You are being transferred to a human agent. Please hold on.",
|
|
306
|
+
{
|
|
307
|
+
tool: "cancel_pending_order",
|
|
308
|
+
args: { order_id: PENDING_ORDER, reason: "no longer needed" },
|
|
309
|
+
},
|
|
310
|
+
"You are being transferred to a human agent. Please hold on.",
|
|
311
|
+
],
|
|
312
|
+
},
|
|
313
|
+
);
|
|
314
|
+
});
|