@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
|
@@ -3,6 +3,16 @@ import { z } from "zod";
|
|
|
3
3
|
import { runCorrectiveRag } from "../procedure.ts";
|
|
4
4
|
import { recordQuestion, supportSlot } from "../shared.ts";
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* How long the whole corrective loop may run before it gives up.
|
|
8
|
+
*
|
|
9
|
+
* Under the runtime's own per-tool deadline (`TOOL_EXECUTION_TIMEOUT_MS`, 30s)
|
|
10
|
+
* with room for this tool's own bookkeeping, and written as a literal because
|
|
11
|
+
* that constant is on `@alexkroman1/aai/internal` — a subpath a template may
|
|
12
|
+
* not import. If the runtime's default ever moves, this is the number to move.
|
|
13
|
+
*/
|
|
14
|
+
const LOOKUP_BUDGET_MS = 28_000;
|
|
15
|
+
|
|
6
16
|
/**
|
|
7
17
|
* The whole graph, as one tool.
|
|
8
18
|
*
|
|
@@ -14,6 +24,33 @@ import { recordQuestion, supportSlot } from "../shared.ts";
|
|
|
14
24
|
* The verdicts ride back with the answer rather than being swallowed. A
|
|
15
25
|
* grounded-but-not-useful answer is still worth speaking, and the caller is the
|
|
16
26
|
* one who should hear that it may not be the whole story.
|
|
27
|
+
*
|
|
28
|
+
* **The loop is given a BUDGET, because the runtime already has one and it is
|
|
29
|
+
* not this tool's.** The corrective path is up to eleven sequential model calls
|
|
30
|
+
* — two attempts of grade/generate/ground/use, plus a regeneration — and the
|
|
31
|
+
* tool executor abandons any tool at `TOOL_EXECUTION_TIMEOUT_MS` (30s), which
|
|
32
|
+
* an author cannot raise. Measured against a live gateway, the two-attempt path
|
|
33
|
+
* really does exceed it: the model was handed
|
|
34
|
+
* `{"error":"Tool \"answer_question\" timed out after 30000ms"}`, which tells
|
|
35
|
+
* it nothing it can act on and reads to a caller as the line going dead.
|
|
36
|
+
* `LOOKUP_BUDGET_MS` lands the overrun on the graph's OWN exit instead — the
|
|
37
|
+
* same "offer to look again, or log a ticket" the design already has for an
|
|
38
|
+
* interrupted run — so the slow path degrades into a sentence rather than into
|
|
39
|
+
* an internal error. It does not make the loop faster; it makes running out
|
|
40
|
+
* something the caller hears about.
|
|
41
|
+
*
|
|
42
|
+
* **Re-measured, and the loop is nowhere near the budget today.** Driven
|
|
43
|
+
* directly against the live gateway on the default model: the single-attempt
|
|
44
|
+
* path is 2.0-3.1s over four or five model calls, and the two-attempt
|
|
45
|
+
* CORRECTIVE path — nine calls, one query rewrite — is 2.8s, with each call
|
|
46
|
+
* 0.3-1.3s. A schema-constrained call is not the expensive kind either (572ms
|
|
47
|
+
* against 553ms for the same prompt without one), which is the guess anyone
|
|
48
|
+
* looking at this would make first. So the budget is a GUARD against a slow
|
|
49
|
+
* gateway rather than a bound the healthy path pushes against, and a question
|
|
50
|
+
* that trips a rewrite fits with room to spare. Keep it: the measurement above
|
|
51
|
+
* happened, and what changed is the service's latency, not the number of round
|
|
52
|
+
* trips. Do not go cutting round trips out of the graph on the strength of the
|
|
53
|
+
* old paragraph — measure first, the same way.
|
|
17
54
|
*/
|
|
18
55
|
export default tool({
|
|
19
56
|
description:
|
|
@@ -28,17 +65,29 @@ export default tool({
|
|
|
28
65
|
try {
|
|
29
66
|
// `ctx.signal` is what stops the graph on a barge-in: this loop is five
|
|
30
67
|
// to nine model calls, and a caller who interrupts on the second should
|
|
31
|
-
// not be charged for the rest.
|
|
32
|
-
|
|
68
|
+
// not be charged for the rest. The budget rides alongside it — see the
|
|
69
|
+
// module doc on `LOOKUP_BUDGET_MS` — and `AbortSignal.any` is what
|
|
70
|
+
// combines the two without any unlink bookkeeping.
|
|
71
|
+
trace = await runCorrectiveRag(
|
|
72
|
+
ctx.generate,
|
|
73
|
+
args.question,
|
|
74
|
+
AbortSignal.any([ctx.signal, AbortSignal.timeout(LOOKUP_BUDGET_MS)]),
|
|
75
|
+
);
|
|
33
76
|
} catch (err: unknown) {
|
|
34
77
|
// An INTERRUPTED lookup is not a broken one, and the difference is worth
|
|
35
|
-
// a sentence:
|
|
36
|
-
//
|
|
37
|
-
//
|
|
78
|
+
// a sentence: telling the model the knowledge base failed would have it
|
|
79
|
+
// apologize for an outage that did not happen. Which of the two signals
|
|
80
|
+
// fired decides WHICH sentence — a barge-in means the caller is already
|
|
81
|
+
// talking, and a spent budget means they are still waiting.
|
|
38
82
|
if (err instanceof ProcedureNotFinishedError) {
|
|
39
83
|
return toolFailure(
|
|
40
|
-
|
|
41
|
-
"
|
|
84
|
+
ctx.signal.aborted
|
|
85
|
+
? "That lookup was cut short before it finished. Offer to look again, " +
|
|
86
|
+
"or to log a ticket with log_ticket."
|
|
87
|
+
: "That lookup ran out of time before it could be graded, so there is no " +
|
|
88
|
+
"answer to give. Say the check is taking too long, offer a narrower " +
|
|
89
|
+
"question, and offer to log a ticket with log_ticket. Do not answer " +
|
|
90
|
+
"from your own knowledge.",
|
|
42
91
|
);
|
|
43
92
|
}
|
|
44
93
|
// A broken model call IS the tool's to report: the model can tell the
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
// An EVAL for a WORKFLOW APP: does the run actually do the work? Run it with
|
|
3
|
+
// `aai eval`.
|
|
4
|
+
//
|
|
5
|
+
// `agent.test.ts` asserts about the declaration, drives the four steps one at a
|
|
6
|
+
// time, and covers the WAV arithmetic as pure functions. This drives the WHOLE
|
|
7
|
+
// BODY — `transcribeFlow` from the top — and what it is here to check is the one
|
|
8
|
+
// thing no per-step spec can see: that a recording is really planned, really
|
|
9
|
+
// fanned out, and really stitched back into one transcript in the right order.
|
|
10
|
+
//
|
|
11
|
+
// `describeWorkflowEval` picks the providers for you and says which it picked:
|
|
12
|
+
//
|
|
13
|
+
// * with `ASSEMBLYAI_API_KEY` — a LIVE run. The `{ live: true }` case below
|
|
14
|
+
// downloads a real four-minute news clip, converts it with a real ffmpeg,
|
|
15
|
+
// and puts four real requests through the sync endpoint. That spends money
|
|
16
|
+
// and about a minute.
|
|
17
|
+
// * without one — a SCRIPTED run: the same body, the same plan, the same
|
|
18
|
+
// stitch, with the endpoint answered in memory.
|
|
19
|
+
//
|
|
20
|
+
// Two of the three cases are SCRIPTED IN BOTH MODES, deliberately. Their claims
|
|
21
|
+
// are about the PLAN and the SEAM — which window each request got, and what the
|
|
22
|
+
// merge does where two segments overlap — and those are facts about arithmetic
|
|
23
|
+
// that a live provider can neither confirm nor deny. The live case is the one
|
|
24
|
+
// that answers "is the transcript really of the recording".
|
|
25
|
+
//
|
|
26
|
+
// WHAT NO EVAL HERE COVERS: durability. Imported through vitest with no bundler
|
|
27
|
+
// in the path, a `"use workflow"` body is an ordinary async function — no
|
|
28
|
+
// journal, no replay, and no per-step retry, so the resume-after-segment-27
|
|
29
|
+
// property this template exists to demonstrate is NOT exercised here, and a
|
|
30
|
+
// rate-limited live run fails where a deployed one would have ridden it out.
|
|
31
|
+
// `aai-cli`'s `dev-workflow.scenario.test.ts` is the tier that really resumes a
|
|
32
|
+
// run.
|
|
33
|
+
import { encodeWav } from "@alexkroman1/aai/step";
|
|
34
|
+
import { installStubTranscribe, installStubUploads } from "@alexkroman1/aai/testing/vitest";
|
|
35
|
+
import { describeWorkflowEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
36
|
+
import { expect } from "vitest";
|
|
37
|
+
import agentDef, { transcribe } from "./agent.ts";
|
|
38
|
+
import { TRANSCRIPT_STREAM } from "./workflows/stitch.ts";
|
|
39
|
+
import { SEGMENT_SECONDS } from "./workflows/wav.ts";
|
|
40
|
+
|
|
41
|
+
/** The id every case uploads under. */
|
|
42
|
+
const UPLOAD_ID = "upl_eval";
|
|
43
|
+
|
|
44
|
+
/** 16 kHz mono 16-bit — the format `normalize.ts` converts everything TO. */
|
|
45
|
+
const MONO_16K = { sampleRate: 16_000, channels: 1, bitsPerSample: 16 } as const;
|
|
46
|
+
/** Bytes one second of that format occupies. */
|
|
47
|
+
const BYTES_PER_SECOND = MONO_16K.sampleRate * MONO_16K.channels * (MONO_16K.bitsPerSample / 8);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Long enough to force a THREE-segment fan-out, which is the shape worth
|
|
51
|
+
* driving: one segment exercises no plan, two exercise one seam, three exercise
|
|
52
|
+
* a middle segment that is neither first nor last.
|
|
53
|
+
*/
|
|
54
|
+
const SCRIPTED_SECONDS = 200;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A real, parseable, linear-PCM WAV of silence.
|
|
58
|
+
*
|
|
59
|
+
* Silence is fine here because the scripted cases never send it anywhere: what
|
|
60
|
+
* they assert is which BYTE RANGE each request was handed, and the bytes only
|
|
61
|
+
* have to be as long as the header says. It also takes the fast path through
|
|
62
|
+
* `normalizeRecording` — `parseWav` accepts it, so no ffmpeg is involved, which
|
|
63
|
+
* is what keeps these two cases runnable with no binary on `PATH`.
|
|
64
|
+
*/
|
|
65
|
+
const SCRIPTED_WAV = encodeWav(new Uint8Array(SCRIPTED_SECONDS * BYTES_PER_SECOND), MONO_16K);
|
|
66
|
+
|
|
67
|
+
/** The public sample recording — four minutes of real speech, on a real CDN. */
|
|
68
|
+
const LIVE_RECORDING = "https://assembly.ai/wildfires.mp3";
|
|
69
|
+
|
|
70
|
+
/** Publish one in-memory upload store, writable because the converter needs one. */
|
|
71
|
+
function publish(bytes: Uint8Array, name: string, type: string) {
|
|
72
|
+
// `writable: true` is not optional for this template: `normalizeRecording`
|
|
73
|
+
// writes the CONVERTED file back as a new upload, and a read-only store would
|
|
74
|
+
// fail that step by name — which is the store telling the truth, and not what
|
|
75
|
+
// these cases are about.
|
|
76
|
+
return installStubUploads({ [UPLOAD_ID]: { bytes, name, type } }, { writable: true });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Does this request body carry a WAV header?
|
|
81
|
+
*
|
|
82
|
+
* Scanned as BYTES rather than decoded: the body is a multipart envelope around
|
|
83
|
+
* megabytes of audio, so `String(bytes)` is a comma-joined number list — which
|
|
84
|
+
* makes the assertion pass or fail for the wrong reason and prints five
|
|
85
|
+
* megabytes of it when it fails.
|
|
86
|
+
*/
|
|
87
|
+
function carriesWavHeader(body: Uint8Array | string | undefined): boolean {
|
|
88
|
+
if (!(body instanceof Uint8Array)) return false;
|
|
89
|
+
const riff = [0x52, 0x49, 0x46, 0x46];
|
|
90
|
+
for (let at = 0; at <= body.length - riff.length; at++) {
|
|
91
|
+
if (riff.every((byte, offset) => body[at + offset] === byte)) return true;
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The transcript chunks the run streamed, in the order they landed. */
|
|
97
|
+
function streamed(run: { emitted: readonly { namespace: string; chunk: unknown }[] }) {
|
|
98
|
+
return run.emitted
|
|
99
|
+
.filter((one) => one.namespace === TRANSCRIPT_STREAM)
|
|
100
|
+
.map((one) => one.chunk as { index: number; startMs: number; endMs: number; text: string });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
describeWorkflowEval(agentDef, (test) => {
|
|
104
|
+
test("plans the fan-out from the header and gives each request its own window", async ({
|
|
105
|
+
app,
|
|
106
|
+
}) => {
|
|
107
|
+
// Scripted in both modes: the claim is which WINDOW each of the three
|
|
108
|
+
// requests was handed, and a live endpoint cannot answer that — it can only
|
|
109
|
+
// transcribe whatever it is sent. This is the case that catches an
|
|
110
|
+
// off-by-one in the plan, which otherwise produces audio the decoder
|
|
111
|
+
// transcribes into confident nonsense.
|
|
112
|
+
publish(SCRIPTED_WAV, "standup.wav", "audio/wav");
|
|
113
|
+
const provider = installStubTranscribe({
|
|
114
|
+
text: ["the first stretch", "the middle stretch", "the last stretch"],
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const run = await app.run(transcribe, { recording: UPLOAD_ID });
|
|
118
|
+
|
|
119
|
+
expect(run.error).toBeUndefined();
|
|
120
|
+
expect(run.status).toBe("completed");
|
|
121
|
+
const output = run.output;
|
|
122
|
+
if (output === undefined) expect.fail("a completed run must carry an output");
|
|
123
|
+
|
|
124
|
+
// Three segments over 200 seconds at a 90-second stride, and the durations
|
|
125
|
+
// are the plan: each segment carries the 2-second OVERLAP that stops a cut
|
|
126
|
+
// landing mid-word, so they deliberately do not abut.
|
|
127
|
+
expect(output.segments).toBe(3);
|
|
128
|
+
expect(output.durationMs).toBe(SCRIPTED_SECONDS * 1000);
|
|
129
|
+
expect(streamed(run).map((chunk) => [chunk.index, chunk.startMs, chunk.endMs])).toEqual([
|
|
130
|
+
[0, 0, (SEGMENT_SECONDS + 2) * 1000],
|
|
131
|
+
[1, SEGMENT_SECONDS * 1000, (2 * SEGMENT_SECONDS + 2) * 1000],
|
|
132
|
+
[2, 2 * SEGMENT_SECONDS * 1000, SCRIPTED_SECONDS * 1000],
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
// One sync request per segment, each carrying a WHOLE WAV — the endpoint
|
|
136
|
+
// decodes every request independently, so a window with no header put back
|
|
137
|
+
// on it is bytes it refuses.
|
|
138
|
+
const sync = provider.calls.filter((call) => call.leg === "sync");
|
|
139
|
+
expect(sync).toHaveLength(3);
|
|
140
|
+
expect(sync.map((call) => carriesWavHeader(call.body))).toEqual([true, true, true]);
|
|
141
|
+
// The first two segments are a full stride plus the overlap; the last is the
|
|
142
|
+
// remainder, and is therefore much smaller. A plan that ran off the end of
|
|
143
|
+
// the file would show up here as a last segment the size of the others.
|
|
144
|
+
const sizes = sync.map((call) => call.body?.length ?? 0);
|
|
145
|
+
expect(sizes[0]).toBeGreaterThan((SEGMENT_SECONDS + 1) * BYTES_PER_SECOND);
|
|
146
|
+
expect(sizes[2]).toBeLessThan(sizes[0] ?? 0);
|
|
147
|
+
|
|
148
|
+
// Stitched in INDEX order, not completion order.
|
|
149
|
+
expect(output.transcript).toBe("the first stretch the middle stretch the last stretch");
|
|
150
|
+
// Reported before the fan-out, which is what makes a long run legible.
|
|
151
|
+
expect(run.reported).toContain("Split 3:20 of audio into 3 segments.");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("drops the repeated words where two segments overlap", async ({ app }) => {
|
|
155
|
+
// Scripted in both modes for the same reason: the overlap exists so a cut
|
|
156
|
+
// never lands mid-word, and what makes that free is the merge dropping ONE
|
|
157
|
+
// copy of the repeated run. A live provider cannot be asked to produce a
|
|
158
|
+
// seam on demand, and this is where a regression would be invisible — a
|
|
159
|
+
// missed seam repeats a few words, a false one DELETES speech.
|
|
160
|
+
publish(SCRIPTED_WAV, "standup.wav", "audio/wav");
|
|
161
|
+
installStubTranscribe({
|
|
162
|
+
text: [
|
|
163
|
+
"the fire moved north through the valley overnight",
|
|
164
|
+
"through the valley overnight and reached the ridge by dawn",
|
|
165
|
+
"by dawn the crews had contained it",
|
|
166
|
+
],
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const run = await app.run(transcribe, { recording: UPLOAD_ID });
|
|
170
|
+
|
|
171
|
+
expect(run.error).toBeUndefined();
|
|
172
|
+
const transcript = run.output?.transcript ?? "";
|
|
173
|
+
expect(transcript).toBe(
|
|
174
|
+
"the fire moved north through the valley overnight and reached the ridge by dawn the crews had contained it",
|
|
175
|
+
);
|
|
176
|
+
// The repeated runs appear once each, which is the whole claim.
|
|
177
|
+
expect(transcript.match(/overnight/g)).toHaveLength(1);
|
|
178
|
+
expect(transcript.match(/by dawn/g)).toHaveLength(1);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test(
|
|
182
|
+
"really transcribes a real recording, end to end",
|
|
183
|
+
async ({ app }) => {
|
|
184
|
+
// LIVE ONLY, and it is the case that earns the template its name. Three
|
|
185
|
+
// separate things have to be real for it to pass: ffmpeg has to convert an
|
|
186
|
+
// MP3 into something the plan can cut, the plan has to cover the whole
|
|
187
|
+
// recording, and every segment's request has to come back with the words
|
|
188
|
+
// that are actually in it.
|
|
189
|
+
//
|
|
190
|
+
// It needs `ffmpeg` on `PATH` (or `AAI_FFMPEG_PATH`), which is what the
|
|
191
|
+
// template's own doc says a developer needs for anything that is not
|
|
192
|
+
// already a WAV. A deployed guest's image installs it.
|
|
193
|
+
const response = await fetch(LIVE_RECORDING);
|
|
194
|
+
expect(response.ok).toBe(true);
|
|
195
|
+
const mp3 = new Uint8Array(await response.arrayBuffer());
|
|
196
|
+
publish(mp3, "wildfires.mp3", "audio/mpeg");
|
|
197
|
+
|
|
198
|
+
const run = await app.run(transcribe, { recording: UPLOAD_ID });
|
|
199
|
+
|
|
200
|
+
expect(run.error).toBeUndefined();
|
|
201
|
+
expect(run.status).toBe("completed");
|
|
202
|
+
const output = run.output;
|
|
203
|
+
if (output === undefined) expect.fail("a completed run must carry an output");
|
|
204
|
+
|
|
205
|
+
// ffmpeg ran, and the run said so before spending minutes on it.
|
|
206
|
+
expect(run.reported.some((line) => line.startsWith("Converting wildfires.mp3"))).toBe(true);
|
|
207
|
+
// The FILENAME a reader sees is the one they uploaded, not the converted
|
|
208
|
+
// artifact's — `mergeTranscript` reads the ORIGINAL id for exactly this.
|
|
209
|
+
expect(output.source).toBe("wildfires.mp3");
|
|
210
|
+
|
|
211
|
+
// Four and a half minutes, so the plan really fanned out rather than
|
|
212
|
+
// sending one request.
|
|
213
|
+
expect(output.segments).toBeGreaterThanOrEqual(3);
|
|
214
|
+
expect(output.durationMs).toBeGreaterThan(250_000);
|
|
215
|
+
|
|
216
|
+
// Every segment came back with words in it, which is how "the whole
|
|
217
|
+
// recording was covered" is checked rather than assumed: a plan that ran
|
|
218
|
+
// past the end would leave a silent tail segment here.
|
|
219
|
+
const chunks = streamed(run);
|
|
220
|
+
expect(chunks).toHaveLength(output.segments);
|
|
221
|
+
for (const chunk of chunks) expect(chunk.text.length).toBeGreaterThan(0);
|
|
222
|
+
|
|
223
|
+
// And the transcript is of THIS recording — a news segment about smoke
|
|
224
|
+
// from Canadian wildfires reaching the US east coast.
|
|
225
|
+
expect(output.transcript).toMatch(/wildfire/i);
|
|
226
|
+
expect(output.transcript).toMatch(/canada/i);
|
|
227
|
+
expect(output.transcript).toMatch(/air quality/i);
|
|
228
|
+
// ~4.5 minutes of speech is several hundred words; a stitch that kept only
|
|
229
|
+
// the first segment would land far under this.
|
|
230
|
+
expect(output.words).toBeGreaterThan(400);
|
|
231
|
+
},
|
|
232
|
+
{ live: true },
|
|
233
|
+
);
|
|
234
|
+
});
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
|
|
3
|
+
// An EVAL: does the confirmation gate actually gate? Run it with `aai eval`.
|
|
4
|
+
//
|
|
5
|
+
// `agent.test.ts` drives each tool directly and asserts about the state it
|
|
6
|
+
// wrote. That is the right tier for "does `stageAction` refuse a second
|
|
7
|
+
// staging" — and it cannot answer the question this template exists to
|
|
8
|
+
// demonstrate, which is whether the gate still holds when a MODEL is the one
|
|
9
|
+
// picking tools. So these cases drive a real session and read the mechanism off
|
|
10
|
+
// the event stream: which tool ran, what the gated tool answered, and — the
|
|
11
|
+
// claim that matters — whether the caller's booking moved before they said yes.
|
|
12
|
+
//
|
|
13
|
+
// Two modes, per `describeEval`: with a key, a live model chooses the tools;
|
|
14
|
+
// without one, each case's `stubReply` scripts them. A scripted tool call really
|
|
15
|
+
// executes, so the flow gate really runs either way — what a stub run cannot
|
|
16
|
+
// tell you is whether the model would have chosen that tool.
|
|
17
|
+
//
|
|
18
|
+
// **`system-prompt.md` is applied HERE, not by `agent.ts`.** The build discovers
|
|
19
|
+
// the file, so an eval driving the raw default export would run this agent with
|
|
20
|
+
// the FRAMEWORK DEFAULT prompt — and the discipline that prompt imposes is the
|
|
21
|
+
// entire subject of this file. Measured against the default: the model answers
|
|
22
|
+
// product questions from its own knowledge and skips the tools the prompt exists
|
|
23
|
+
// to route it through, so a case run that way measures nothing it claims to.
|
|
24
|
+
|
|
25
|
+
import { withSystemPrompt } from "@alexkroman1/aai/manifest";
|
|
26
|
+
import { withDiscoveredTools } from "@alexkroman1/aai/testing";
|
|
27
|
+
import { type EvalSession, type EvalTurn, lastStateIn } from "@alexkroman1/aai-runtime/eval";
|
|
28
|
+
import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
29
|
+
import { expect } from "vitest";
|
|
30
|
+
import { z } from "zod";
|
|
31
|
+
import authoredAgent from "./agent.ts";
|
|
32
|
+
import systemPrompt from "./system-prompt.md?raw";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The def a DEPLOYED agent runs: authored, plus what `tools/` declares.
|
|
36
|
+
*
|
|
37
|
+
* The glob is written HERE rather than reached for from a shared helper for the
|
|
38
|
+
* reason `agent.test.ts` gives — this file ships, so it may not import anything
|
|
39
|
+
* outside its own template. An eval that forgot it would run an agent with NO
|
|
40
|
+
* tools and read as a model that refuses to act.
|
|
41
|
+
*/
|
|
42
|
+
const agentDef = withSystemPrompt(
|
|
43
|
+
withDiscoveredTools(authoredAgent, import.meta.glob("./tools/*.ts", { eager: true })),
|
|
44
|
+
systemPrompt,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* What the BROWSER is sent, as this eval reads it.
|
|
49
|
+
*
|
|
50
|
+
* Parsed rather than cast: `state.updated` carries `unknown`, so a projection
|
|
51
|
+
* that stopped matching FAILS naming the field, where the cast this replaced
|
|
52
|
+
* handed the assertions `undefined` and failed a line later on something else.
|
|
53
|
+
* It names only the fields asserted below, so `tripView` may grow without
|
|
54
|
+
* touching this.
|
|
55
|
+
*/
|
|
56
|
+
const ProjectedTrip = z.object({
|
|
57
|
+
assistant: z.string(),
|
|
58
|
+
ticket: z.object({ flightId: z.string() }).nullable(),
|
|
59
|
+
bookings: z.array(z.unknown()),
|
|
60
|
+
pending: z.string().nullable(),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The `syncState` frames, in stream order, UP TO the first `confirm_action`.
|
|
65
|
+
*
|
|
66
|
+
* This is what makes "a staging tool mutates nothing" assertable against a live
|
|
67
|
+
* model: the caller's later yes is allowed to move the ticket, so a claim about
|
|
68
|
+
* the FINAL state is a claim about the conversation rather than about the gate.
|
|
69
|
+
* What the gate promises is that every frame before the confirmation shows the
|
|
70
|
+
* booking untouched — and `tripProjection` rides out on `state.updated` after
|
|
71
|
+
* every tool call, so the stream carries one per step.
|
|
72
|
+
*/
|
|
73
|
+
function framesBeforeConfirm(session: EvalSession): z.infer<typeof ProjectedTrip>[] {
|
|
74
|
+
const views: z.infer<typeof ProjectedTrip>[] = [];
|
|
75
|
+
for (const event of session.events()) {
|
|
76
|
+
if (event.type === "tool.called" && event.toolName === "confirm_action") break;
|
|
77
|
+
if (event.type === "state.updated") views.push(ProjectedTrip.parse(event.state));
|
|
78
|
+
}
|
|
79
|
+
return views;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The latest frame — what the browser would be rendering now. `lastStateIn` is
|
|
84
|
+
* the SDK's reader for exactly this; the schema is why it is worth passing one.
|
|
85
|
+
*/
|
|
86
|
+
const tripState = (session: EvalSession) => lastStateIn(session.events(), ProjectedTrip);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Drive a whole call, one caller line at a time, and hand back every turn.
|
|
90
|
+
*
|
|
91
|
+
* The cases below assert about the turn a MECHANISM fired in rather than about
|
|
92
|
+
* turn one, because how many turns a desk takes to get there is the model's
|
|
93
|
+
* business and it moved when the desk gate landed: the flight desk's brief says
|
|
94
|
+
* to search before quoting anything, so measured live this concierge now spends
|
|
95
|
+
* its first turn on `to_flight_assistant` and `search_flights` and reads the
|
|
96
|
+
* fare back before it stages. A case pinned to turn one is a flake with a
|
|
97
|
+
* misleading name — the same argument `retail`'s eval carries.
|
|
98
|
+
*/
|
|
99
|
+
async function sayAll(session: EvalSession, lines: readonly string[]): Promise<EvalTurn[]> {
|
|
100
|
+
const turns: EvalTurn[] = [];
|
|
101
|
+
for (const line of lines) turns.push(await session.say(line));
|
|
102
|
+
return turns;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Every tool call of the call so far, flattened, in order. */
|
|
106
|
+
const callsIn = (turns: readonly EvalTurn[]) => turns.flatMap((turn) => turn.toolCalls);
|
|
107
|
+
|
|
108
|
+
/** The turn a named tool STAGED something in — the call that answered with the
|
|
109
|
+
* read-back rather than with a gate's refusal. */
|
|
110
|
+
const stagingTurn = (turns: readonly EvalTurn[], tool: string) =>
|
|
111
|
+
turns.find((turn) =>
|
|
112
|
+
turn.toolCalls.some(
|
|
113
|
+
(call) => call.name === tool && /awaitingConfirmation/.test(call.result ?? ""),
|
|
114
|
+
),
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
describeEval(agentDef, (test) => {
|
|
118
|
+
test(
|
|
119
|
+
"a sensitive tool stages the change and moves nothing",
|
|
120
|
+
async ({ session }) => {
|
|
121
|
+
// Three lines, each of them the same REQUEST and none of them an answer to
|
|
122
|
+
// a read-back: what is asserted below is that the turn which staged did
|
|
123
|
+
// not also apply, so a line the model could read as consent ("correct",
|
|
124
|
+
// "that's right") would be measuring the caller instead of the desk.
|
|
125
|
+
const turns = await sayAll(session, [
|
|
126
|
+
"Move my ticket to flight LX52, the Wednesday one.",
|
|
127
|
+
"I want the Wednesday LX52 instead of the flight I'm on now.",
|
|
128
|
+
"Put me on LX52 on Wednesday, please.",
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
const staging = stagingTurn(turns, "update_ticket");
|
|
132
|
+
const attempts = callsIn(turns).filter((call) => call.name === "update_ticket");
|
|
133
|
+
// Named with the whole call, tools AND text: "expected undefined to be
|
|
134
|
+
// defined" says nothing about a desk that talked its way through three
|
|
135
|
+
// turns without staging, which is exactly the failure this case caught
|
|
136
|
+
// while the flight desk's brief had the read-back before the staging.
|
|
137
|
+
expect(
|
|
138
|
+
staging,
|
|
139
|
+
turns
|
|
140
|
+
.map(
|
|
141
|
+
(turn, i) =>
|
|
142
|
+
`turn ${i + 1}: [${turn.toolCalls.map((c) => c.name).join(", ")}] said: ${turn.text}`,
|
|
143
|
+
)
|
|
144
|
+
.join("\n"),
|
|
145
|
+
).toBeDefined();
|
|
146
|
+
const staged = staging?.toolCalls.find((call) => call.name === "update_ticket");
|
|
147
|
+
// The tool answered with the read-back rather than with a receipt.
|
|
148
|
+
expect(staged?.result).toMatch(/awaitingConfirmation/);
|
|
149
|
+
// Any attempt that did NOT stage is the DESK GATE refusing:
|
|
150
|
+
// `update_ticket` belongs to the flight desk, so a model reaching for it
|
|
151
|
+
// before `to_flight_assistant` is told so and recovers inside the turn.
|
|
152
|
+
// That refusal is a legal step and it is asserted rather than tolerated —
|
|
153
|
+
// its own message is what pointed the model at the way in.
|
|
154
|
+
for (const attempt of attempts.filter((call) => call !== staged)) {
|
|
155
|
+
expect(attempt.result).toMatch(/to_flight_assistant|Not available yet/);
|
|
156
|
+
}
|
|
157
|
+
// The desk asks; it does not decide. A `confirm_action` AFTER the staging
|
|
158
|
+
// in the same turn is the agent confirming on its own initiative, which
|
|
159
|
+
// the system prompt forbids in as many words.
|
|
160
|
+
//
|
|
161
|
+
// After, not anywhere in the turn: measured live, a model that hears a
|
|
162
|
+
// second request as a yes reaches for `confirm_action` BEFORE it has
|
|
163
|
+
// staged anything, the gate refuses it (the case above is where that
|
|
164
|
+
// refusal is the subject), and it then stages properly. That is a wasted
|
|
165
|
+
// step rather than an unasked-for change, and folding the two together
|
|
166
|
+
// would fail this case for the behaviour the next one proves is safe.
|
|
167
|
+
// Narrowed first: `indexOf` takes a value, and `staged` is optional — the
|
|
168
|
+
// rewrite Biome offers for `findIndex` over an identity is UNSAFE for
|
|
169
|
+
// exactly that reason, and the assertion above is what makes an absent
|
|
170
|
+
// staging call a failure rather than a slice from 0.
|
|
171
|
+
const stagedAt = staged === undefined ? -1 : (staging?.toolCalls.indexOf(staged) ?? -1);
|
|
172
|
+
expect(staging?.toolCalls.slice(stagedAt + 1).map((call) => call.name) ?? []).not.toContain(
|
|
173
|
+
"confirm_action",
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const views = framesBeforeConfirm(session);
|
|
177
|
+
const waiting = views.filter((view) => view.pending !== null);
|
|
178
|
+
expect(waiting.length, "no frame ever showed a staged change").toBeGreaterThan(0);
|
|
179
|
+
expect(waiting.at(-1)?.pending).toMatch(/LX52/);
|
|
180
|
+
// THE claim: through every frame up to the confirmation, the ticket is
|
|
181
|
+
// still the one the caller phoned in on. A staging tool that mutated
|
|
182
|
+
// would satisfy every assertion above this one.
|
|
183
|
+
for (const view of views) expect(view.ticket?.flightId).toBe("LX40");
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
stubReply: [
|
|
187
|
+
{ tool: "to_flight_assistant", args: { request: "move my ticket to LX52" } },
|
|
188
|
+
{ tool: "update_ticket", args: { flightId: "LX52" } },
|
|
189
|
+
"That would move you to LX52 on Wednesday, five ninety. Shall I go ahead?",
|
|
190
|
+
],
|
|
191
|
+
},
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
test(
|
|
195
|
+
"confirm_action is refused while nothing is waiting",
|
|
196
|
+
async ({ session, mode }) => {
|
|
197
|
+
const turn = await session.say("Yes, I confirm — go ahead and do it.");
|
|
198
|
+
|
|
199
|
+
const attempts = turn.toolCalls.filter((call) => call.name === "confirm_action");
|
|
200
|
+
// In stub mode the script FORCES the call, so the gate is really
|
|
201
|
+
// exercised; a live model that declines to call it at all has honoured
|
|
202
|
+
// the same rule one level earlier, which is why the count is asserted
|
|
203
|
+
// only where it is determined.
|
|
204
|
+
if (mode === "stub") expect(attempts).toHaveLength(1);
|
|
205
|
+
for (const attempt of attempts) {
|
|
206
|
+
// The refusal names the position and quotes the state's instruction —
|
|
207
|
+
// that is what the model recovers from, and a gate that ran the body
|
|
208
|
+
// and then apologized would not carry it.
|
|
209
|
+
expect(attempt.result).toMatch(/Not available yet/);
|
|
210
|
+
expect(attempt.result).toMatch(/browsing/);
|
|
211
|
+
}
|
|
212
|
+
expect(tripState(session)?.pending ?? null).toBeNull();
|
|
213
|
+
expect(tripState(session)?.bookings ?? []).toHaveLength(0);
|
|
214
|
+
},
|
|
215
|
+
{ stubReply: [{ tool: "confirm_action" }, "Nothing is waiting for your yes just now."] },
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
test(
|
|
219
|
+
"the caller's yes is the only thing that moves the ticket",
|
|
220
|
+
async ({ session }) => {
|
|
221
|
+
// Four lines, three of them a yes: which turn the desk stages in and which
|
|
222
|
+
// it applies in is its own business — the flight desk's brief has it
|
|
223
|
+
// search first — and saying yes repeatedly is what makes "once each"
|
|
224
|
+
// below a claim about the MECHANISM rather than about the model's pacing.
|
|
225
|
+
await sayAll(session, [
|
|
226
|
+
"Move my ticket to flight LX52, the Wednesday one.",
|
|
227
|
+
"Correct — LX52 on Wednesday. Please move my ticket to it.",
|
|
228
|
+
"Yes, that's right — go ahead and change it.",
|
|
229
|
+
"Yes. Confirm it, please.",
|
|
230
|
+
]);
|
|
231
|
+
|
|
232
|
+
// The two gated tools' calls, in order.
|
|
233
|
+
const gated = session
|
|
234
|
+
.toolCalls()
|
|
235
|
+
.filter((call) => ["update_ticket", "confirm_action"].includes(call.name));
|
|
236
|
+
// "Did something" is read POSITIVELY — a staging answers
|
|
237
|
+
// `awaitingConfirmation`, an apply answers `applied` — so the complement
|
|
238
|
+
// is a claim rather than a definition.
|
|
239
|
+
const effective = gated.filter((call) =>
|
|
240
|
+
/awaitingConfirmation|"applied"/.test(call.result ?? ""),
|
|
241
|
+
);
|
|
242
|
+
// Staged first, applied second, once each. Reversed — or a confirm with no
|
|
243
|
+
// stage — is the regression this template's whole shape exists to prevent.
|
|
244
|
+
expect(effective.map((call) => call.name)).toEqual(["update_ticket", "confirm_action"]);
|
|
245
|
+
// Everything else has to be a GATE refusing, and nothing else: the desk
|
|
246
|
+
// gate turns away an `update_ticket` issued before
|
|
247
|
+
// `to_flight_assistant`, and the confirmation gate turns away a
|
|
248
|
+
// `confirm_action` issued before anything is staged (the case above is
|
|
249
|
+
// where that is the subject). Both are steps a live model really takes,
|
|
250
|
+
// neither moved the ticket, and neither is what this case is about.
|
|
251
|
+
for (const call of gated.filter((c) => !effective.includes(c))) {
|
|
252
|
+
expect(call.result, `${call.name} neither acted nor was refused`).toMatch(
|
|
253
|
+
/Not available yet|belongs to the/,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const applied = effective.at(-1);
|
|
258
|
+
expect(applied?.result).toMatch(/LX52/);
|
|
259
|
+
|
|
260
|
+
const view = tripState(session);
|
|
261
|
+
expect(view?.ticket?.flightId).toBe("LX52");
|
|
262
|
+
expect(view?.pending ?? null).toBeNull();
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
stubReply: [
|
|
266
|
+
// The delegation is in the script because the DESK GATE is real: the
|
|
267
|
+
// flight desk's tools refuse until `to_flight_assistant` has run, so a
|
|
268
|
+
// script that skipped it would exercise that refusal rather than this
|
|
269
|
+
// case's subject.
|
|
270
|
+
{ tool: "to_flight_assistant", args: { request: "move my ticket to LX52" } },
|
|
271
|
+
{ tool: "update_ticket", args: { flightId: "LX52" } },
|
|
272
|
+
"That's LX52 on Wednesday at five ninety. Confirm it?",
|
|
273
|
+
{ tool: "confirm_action" },
|
|
274
|
+
"Done — you're on LX52 on Wednesday.",
|
|
275
|
+
],
|
|
276
|
+
},
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
test(
|
|
280
|
+
"a hotel request goes to the hotel desk rather than being answered at the front",
|
|
281
|
+
async ({ session }) => {
|
|
282
|
+
const turn = await session.say(
|
|
283
|
+
"Leave the flight for now — I need a hotel in Boston for three nights, near the water.",
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
// The INDEX as well as the call, because what came before it is asserted
|
|
287
|
+
// below and `indexOf` on a possibly-undefined find is worse than both.
|
|
288
|
+
const handoffAt = turn.toolCalls.findIndex((call) => call.name === "to_hotel_assistant");
|
|
289
|
+
const handoff = turn.toolCalls[handoffAt];
|
|
290
|
+
expect(
|
|
291
|
+
handoff,
|
|
292
|
+
`tools called: [${turn.toolCalls.map((c) => c.name).join(", ")}]; said: ${turn.text}`,
|
|
293
|
+
).toBeDefined();
|
|
294
|
+
// The brief IS the tool result, which is the whole port of their
|
|
295
|
+
// per-assistant prompt onto a session whose prompt is fixed at connect.
|
|
296
|
+
expect(handoff?.result).toMatch(/hotel desk/);
|
|
297
|
+
expect(handoff?.args.request).toBeTruthy();
|
|
298
|
+
// And the stack moved, which is what the caller's sidebar renders and what
|
|
299
|
+
// `complete_or_escalate` will pop.
|
|
300
|
+
expect(tripState(session)?.assistant).toBe("hotel");
|
|
301
|
+
// Anything the model reached for BEFORE delegating was refused, and so
|
|
302
|
+
// told nothing about Boston hotels: the desk gate is what makes the
|
|
303
|
+
// handoff above unavoidable rather than requested. This is the assertion
|
|
304
|
+
// that would have caught the old behaviour even if the model had happened
|
|
305
|
+
// to delegate afterwards.
|
|
306
|
+
const before = turn.toolCalls.slice(0, handoffAt);
|
|
307
|
+
for (const call of before.filter((c) => c.name.startsWith("search_"))) {
|
|
308
|
+
expect(call.result).toMatch(/belongs to the/);
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
// Live only: which desk the model hands the call to is exactly the judgement
|
|
312
|
+
// a script would be making on its behalf.
|
|
313
|
+
//
|
|
314
|
+
// This case is what turned the narrowing into a MECHANISM. It was written
|
|
315
|
+
// when the delegation was asked for in the prompt and nothing enforced it —
|
|
316
|
+
// one session has one tool list — and it measured the asking losing 0 of 5:
|
|
317
|
+
// the model called `search_hotels` from the concierge desk every run. Every
|
|
318
|
+
// desk tool now checks the stack (`requireDesk` in `shared.ts`), so what
|
|
319
|
+
// this measures is that the refusal really does route the model through the
|
|
320
|
+
// desk, inside one turn.
|
|
321
|
+
{ live: true },
|
|
322
|
+
);
|
|
323
|
+
});
|