@alexkroman1/aai-cli 6.11.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/{_agent-DpH2pBJd.mjs → _agent-BTwZJu4P.mjs} +2 -2
- package/dist/{_bundler-C2BDwU7r.mjs → _bundler-CDuYl5Gb.mjs} +2 -2
- package/dist/_dev-env.d.ts +22 -2
- package/dist/{_dev-server-LLLWnFBs.mjs → _dev-server-Me06wgeF.mjs} +38 -47
- package/dist/_dev-server.d.ts +0 -17
- package/dist/{_init-C57bWAQ8.mjs → _init-BhEfQ6Yi.mjs} +3 -2
- package/dist/{_preflight-BtfaYtbE.mjs → _preflight-Co1G9Jww.mjs} +1 -1
- package/dist/_preflight.d.ts +1 -1
- package/dist/_resource-commands.d.ts +23 -0
- package/dist/{_slug-api-BxrsJXVI.mjs → _slug-api-z-USxPtL.mjs} +16 -12
- package/dist/_slug-api.d.ts +5 -2
- package/dist/{_studio-U3jhKlA3.mjs → _studio-DR5yzZ1T.mjs} +2 -1
- package/dist/{_templates-Dxb_P2Wz.mjs → _templates-4WcKOjS5.mjs} +5 -6
- package/dist/_templates.d.ts +3 -4
- package/dist/_workflow-scan.d.ts +108 -0
- package/dist/{build-Dfza2pRI.mjs → build-BBxf8vom.mjs} +6 -3
- package/dist/cli.mjs +346 -349
- package/dist/{client-bundler-BEIqgOtd.mjs → client-bundler-CWnG42cU.mjs} +2 -1
- package/dist/client-bundler.mjs +1 -1
- package/dist/{delete-vbpjGzqs.mjs → delete-Dl_LSfvI.mjs} +3 -3
- package/dist/{deploy-B8lubiRT.mjs → deploy-DRC8jWr8.mjs} +5 -5
- package/dist/{dev-Uq5ujP8a.mjs → dev-BvcctCn2.mjs} +1 -1
- package/dist/eval-DfQ8cm5p.mjs +56 -0
- package/dist/eval.d.ts +43 -0
- package/dist/{init-l6xfU4xX.mjs → init-CUo87gcI.mjs} +3 -3
- package/dist/{login-C71-qz8F.mjs → login-BBuM1sxH.mjs} +3 -4
- package/dist/{logs-BWVGSjLR.mjs → logs-CQ2T46dr.mjs} +2 -2
- package/dist/project-config.mjs +1 -1
- package/dist/scaffold/CLAUDE.md +442 -80
- package/dist/scaffold/package.json +6 -3
- package/dist/scaffold/server.mjs +11 -4
- package/dist/scaffold/tsconfig.json +0 -1
- package/dist/{secret-BuMuFR4B.mjs → secret-Ct34kdgU.mjs} +2 -2
- package/dist/{storage-DzRZ-eCw.mjs → storage-i_79ZwAR.mjs} +2 -2
- package/dist/{studio-BCNUpDgP.mjs → studio-CzTzPqe1.mjs} +4 -4
- 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/call-audit/agent.test.ts +57 -204
- package/dist/templates/call-audit/agent.ts +32 -19
- package/dist/templates/call-audit/client.tsx +17 -54
- package/dist/templates/call-audit/workflows/audit.ts +11 -18
- package/dist/templates/call-audit/workflows/ingest.ts +106 -114
- package/dist/templates/call-audit/workflows/media.ts +2 -12
- package/dist/templates/call-audit/workflows/summarize.ts +47 -52
- package/dist/templates/call-audit/workflows/sync-api.ts +7 -7
- 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/dispatch-center/agent.test.ts +149 -25
- package/dist/templates/dispatch-center/client.tsx +239 -129
- package/dist/templates/dispatch-center/shared.ts +99 -1
- package/dist/templates/dispatch-center/system-prompt.md +3 -1
- package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
- package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
- package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
- package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
- package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
- package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
- package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
- package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
- package/dist/templates/embedded-assets/agent.eval.test.ts +138 -0
- package/dist/templates/embedded-assets/agent.test.ts +17 -14
- package/dist/templates/health-assistant/agent.eval.test.ts +167 -0
- package/dist/templates/health-assistant/agent.test.ts +22 -4
- package/dist/templates/infocom-adventure/agent.eval.test.ts +158 -0
- package/dist/templates/infocom-adventure/agent.test.ts +53 -24
- package/dist/templates/infocom-adventure/agent.ts +21 -0
- package/dist/templates/infocom-adventure/client.tsx +246 -164
- 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/link-digest/agent.test.ts +24 -19
- package/dist/templates/link-digest/client.tsx +47 -61
- package/dist/templates/link-digest/workflows/digest.ts +19 -31
- 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/night-owl/agent.test.ts +70 -19
- package/dist/templates/night-owl/agent.ts +5 -0
- package/dist/templates/night-owl/client.tsx +56 -56
- package/dist/templates/night-owl/shared.ts +24 -0
- package/dist/templates/night-owl/tools/recommend.ts +18 -6
- 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 +42 -27
- package/dist/templates/pizza-ordering/client.tsx +9 -26
- 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/plan-and-execute/agent.test.ts +99 -58
- package/dist/templates/plan-and-execute/agent.ts +1 -1
- package/dist/templates/plan-and-execute/client.tsx +12 -15
- package/dist/templates/plan-and-execute/shared.ts +71 -2
- package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
- package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
- package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
- package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
- package/dist/templates/podcast-digest/agent.eval.test.ts +411 -0
- package/dist/templates/podcast-digest/agent.test.ts +717 -0
- package/dist/templates/podcast-digest/agent.ts +139 -0
- package/dist/templates/podcast-digest/client.tsx +154 -0
- package/dist/templates/podcast-digest/workflows/digest.ts +444 -0
- package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
- package/dist/templates/podcast-digest/workflows/slack.ts +88 -0
- package/dist/templates/recap-workflow/agent.eval.test.ts +366 -0
- package/dist/templates/recap-workflow/agent.test.ts +97 -94
- package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
- package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
- package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
- package/dist/templates/recap-workflow/workflows/recap.ts +36 -40
- package/dist/templates/redline/agent.eval.test.ts +209 -0
- package/dist/templates/redline/agent.test.ts +17 -15
- package/dist/templates/redline/client.tsx +12 -12
- package/dist/templates/redline/workflows/redline.ts +19 -31
- package/dist/templates/research-workflow/agent.eval.test.ts +329 -0
- package/dist/templates/research-workflow/agent.test.ts +60 -59
- package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
- package/dist/templates/research-workflow/tools/research_status.ts +9 -11
- package/dist/templates/research-workflow/workflows/research.ts +44 -61
- 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 +195 -112
- package/dist/templates/retail/agent.ts +35 -1
- package/dist/templates/retail/cancel.ts +116 -0
- package/dist/templates/retail/client.tsx +265 -117
- 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 +150 -7
- 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 +84 -15
- package/dist/templates/retail/store.ts +231 -48
- package/dist/templates/retail/swap.test.ts +4 -4
- package/dist/templates/retail/swap.ts +248 -15
- package/dist/templates/retail/system-prompt.md +69 -28
- package/dist/templates/retail/tools/cancel_change.ts +33 -0
- package/dist/templates/retail/tools/cancel_pending_order.ts +16 -60
- package/dist/templates/retail/tools/confirm_change.ts +42 -0
- package/dist/templates/retail/tools/exchange_delivered_order_items.ts +22 -67
- package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
- package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
- package/dist/templates/retail/tools/get_item_details.ts +3 -4
- package/dist/templates/retail/tools/get_order_details.ts +3 -4
- package/dist/templates/retail/tools/get_product_details.ts +3 -4
- package/dist/templates/retail/tools/get_user_details.ts +2 -2
- package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
- package/dist/templates/retail/tools/modify_pending_order_address.ts +13 -25
- package/dist/templates/retail/tools/modify_pending_order_items.ts +23 -62
- package/dist/templates/retail/tools/modify_pending_order_payment.ts +16 -78
- package/dist/templates/retail/tools/modify_user_address.ts +13 -16
- package/dist/templates/retail/tools/return_delivered_order_items.ts +17 -72
- package/dist/templates/retail/tools/transfer_to_human_agents.ts +12 -6
- package/dist/templates/simple/agent.eval.test.ts +54 -0
- package/dist/templates/solo-rpg/agent.eval.test.ts +320 -0
- package/dist/templates/solo-rpg/agent.test.ts +227 -48
- package/dist/templates/solo-rpg/client.tsx +38 -37
- package/dist/templates/solo-rpg/shared.ts +145 -19
- package/dist/templates/solo-rpg/system-prompt.md +3 -2
- package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
- package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
- package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
- package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
- package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
- package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
- package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
- package/dist/templates/spoken-summary/agent.eval.test.ts +342 -0
- package/dist/templates/spoken-summary/agent.test.ts +68 -97
- package/dist/templates/spoken-summary/agent.ts +30 -17
- package/dist/templates/spoken-summary/client.tsx +10 -50
- package/dist/templates/spoken-summary/workflows/summarize.ts +17 -22
- package/dist/templates/spoken-summary/workflows/transcribe.ts +19 -26
- package/dist/templates/support-line/agent.eval.test.ts +273 -0
- package/dist/templates/support-line/agent.test.ts +11 -16
- package/dist/templates/support-line/agent.ts +1 -1
- package/dist/templates/support-line/client.tsx +9 -9
- package/dist/templates/support-line/nodes.ts +100 -0
- package/dist/templates/support-line/procedure.ts +407 -0
- package/dist/templates/support-line/system-prompt.md +2 -1
- package/dist/templates/support-line/tools/answer_question.ts +66 -4
- package/dist/templates/transcription-workflow/agent.eval.test.ts +234 -0
- package/dist/templates/transcription-workflow/agent.test.ts +94 -194
- package/dist/templates/transcription-workflow/agent.ts +1 -1
- package/dist/templates/transcription-workflow/client.tsx +17 -42
- package/dist/templates/transcription-workflow/workflows/batch.ts +19 -22
- package/dist/templates/transcription-workflow/workflows/normalize.ts +90 -186
- package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
- package/dist/templates/transcription-workflow/workflows/stream.ts +4 -5
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +6 -6
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +32 -26
- package/dist/templates/transcription-workflow/workflows/wav.ts +0 -31
- package/dist/templates/travel-concierge/agent.eval.test.ts +323 -0
- package/dist/templates/travel-concierge/agent.test.ts +174 -43
- package/dist/templates/travel-concierge/agent.ts +18 -9
- package/dist/templates/travel-concierge/client.tsx +11 -23
- package/dist/templates/travel-concierge/routing.ts +34 -15
- package/dist/templates/travel-concierge/shared.ts +180 -12
- package/dist/templates/travel-concierge/system-prompt.md +7 -1
- package/dist/templates/travel-concierge/tools/book_car_rental.ts +8 -5
- package/dist/templates/travel-concierge/tools/book_excursion.ts +8 -5
- package/dist/templates/travel-concierge/tools/book_hotel.ts +8 -5
- package/dist/templates/travel-concierge/tools/cancel_ticket.ts +8 -5
- 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 +8 -5
- 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-CGD4r8Kc.mjs → worker-bundler-CCVEDjm1.mjs} +241 -3
- package/dist/worker-bundler.mjs +1 -1
- package/dist/{workflow-CFpxOFfQ.mjs → workflow-BflATMmU.mjs} +47 -12
- package/dist/workflow-bundler.d.ts +6 -1
- package/dist/workflow.d.ts +1 -1
- package/package.json +5 -4
- package/dist/eject-31gjtaHF.mjs +0 -116
- package/dist/eject.d.ts +0 -60
- package/dist/templates/call-audit/workflows/temp-media.ts +0 -138
- package/dist/templates/infocom-adventure/tools/game_state_history.ts +0 -14
- package/dist/templates/support-line/graph.ts +0 -224
- package/dist/{_config-D_s09e7g.mjs → _config-CmJOFsAP.mjs} +1 -1
- /package/dist/templates/plan-and-execute/{graph.ts → procedure.ts} +0 -0
|
@@ -0,0 +1,342 @@
|
|
|
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 and drives the four legs one at
|
|
6
|
+
// a time. This drives the WHOLE BODY — `spokenSummaryFlow` from the top — and
|
|
7
|
+
// what it is here to check is the thing that is this template's whole reason to
|
|
8
|
+
// exist and that no per-step spec can see: the AUDIO ROUND TRIP. A recording
|
|
9
|
+
// goes in, and what comes out is a run whose OUTPUT NAMES A FILE that is really
|
|
10
|
+
// in the store, put there by the same step that spoke it.
|
|
11
|
+
//
|
|
12
|
+
// `describeWorkflowEval` picks the providers for you and says which it picked:
|
|
13
|
+
//
|
|
14
|
+
// * with `ASSEMBLYAI_API_KEY` — a LIVE run. The `{ live: true }` case below
|
|
15
|
+
// downloads a real four-minute news clip, really transcribes it and really
|
|
16
|
+
// summarizes it. That spends money and about a minute.
|
|
17
|
+
// * without one — a SCRIPTED run: the same body, the same four legs, with the
|
|
18
|
+
// provider answered in memory.
|
|
19
|
+
//
|
|
20
|
+
// Three of the four cases are SCRIPTED IN BOTH MODES, deliberately. Their claims
|
|
21
|
+
// are about WIRING — that the id in the output is the id of the file that was
|
|
22
|
+
// written, that the voice was handed the script and not the bullet list, that
|
|
23
|
+
// the poll loop asks for its wait — and a live provider can neither confirm nor
|
|
24
|
+
// deny any of them: it can only be asked and then have its answer accepted,
|
|
25
|
+
// which is not evidence. The live case is the one that answers "is the summary
|
|
26
|
+
// really of this recording".
|
|
27
|
+
//
|
|
28
|
+
// The UPLOAD STORE is faked in both modes, and that is not a mode question: it
|
|
29
|
+
// is this app's own storage (a database row and a blob), not a provider, and an
|
|
30
|
+
// eval has neither. `{ writable: true }` is what lets the store accept the
|
|
31
|
+
// write — opt-in precisely so a step that stored a file nobody meant it to
|
|
32
|
+
// still fails.
|
|
33
|
+
//
|
|
34
|
+
// TWO PROVIDER LEGS ARE NOT REACHABLE LIVE from an eval today, and both are
|
|
35
|
+
// named where they are worked around rather than left as a surprise:
|
|
36
|
+
//
|
|
37
|
+
// * the streaming UPLOAD leg needs a `stepFetch` — see `liveStepFetch` below.
|
|
38
|
+
// * `stepSpeak` needs a synthesizer, and the engine publishes none by default
|
|
39
|
+
// while `@alexkroman1/aai-runtime` exports no real one to pass. So the VOICE
|
|
40
|
+
// is faked in every case here, live included, and the live case's claim is
|
|
41
|
+
// about the transcript and the summary rather than about audible audio.
|
|
42
|
+
//
|
|
43
|
+
// WHAT NO EVAL HERE COVERS: durability. Imported through vitest with no bundler
|
|
44
|
+
// in the path, a `"use workflow"` body is an ordinary async function — no
|
|
45
|
+
// journal, no replay, and no per-step retry, so a rate-limited live run FAILS
|
|
46
|
+
// where a deployed one would have ridden it out, and the resume-replays-the-id
|
|
47
|
+
// property that makes speak-and-store ONE step is argued here rather than
|
|
48
|
+
// exercised. `run.slept` below is the other half of that admission written as
|
|
49
|
+
// an assertion. `aai-cli`'s `dev-workflow.scenario.test.ts` is the tier that
|
|
50
|
+
// really suspends and resumes a run.
|
|
51
|
+
import {
|
|
52
|
+
installStubSpeech,
|
|
53
|
+
installStubTranscribe,
|
|
54
|
+
installStubUploads,
|
|
55
|
+
} from "@alexkroman1/aai/testing/vitest";
|
|
56
|
+
import { describeWorkflowEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
57
|
+
import { expect } from "vitest";
|
|
58
|
+
import agentDef, { spokenSummary } from "./agent.ts";
|
|
59
|
+
import { POLL_INTERVAL } from "./workflows/transcribe.ts";
|
|
60
|
+
|
|
61
|
+
/** The id every case uploads the recording under. */
|
|
62
|
+
const UPLOAD_ID = "upl_eval";
|
|
63
|
+
|
|
64
|
+
/** The public sample recording — four and a half minutes of real speech. */
|
|
65
|
+
const LIVE_RECORDING = "https://assembly.ai/wildfires.mp3";
|
|
66
|
+
|
|
67
|
+
/** What a scripted transcript says, so a summary of it is checkable. */
|
|
68
|
+
const TRANSCRIPT =
|
|
69
|
+
"Right, standup. The launch is on for Tuesday the fourth. Two bugs are left in " +
|
|
70
|
+
"checkout, both assigned to Priya, and neither is a blocker. Marketing wants the " +
|
|
71
|
+
"blog post by Monday. If the second bug slips we ship anyway and patch on Wednesday.";
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The reply the model is scripted to give.
|
|
75
|
+
*
|
|
76
|
+
* The `spoken` script deliberately shares NO wording with `points`, which is
|
|
77
|
+
* what makes "the voice was handed the script" an assertion rather than a
|
|
78
|
+
* coincidence — see the second case.
|
|
79
|
+
*/
|
|
80
|
+
const REPLY = {
|
|
81
|
+
headline: "Launch is on for Tuesday",
|
|
82
|
+
points: ["Ship Tuesday the fourth", "Two checkout bugs, neither blocking", "Blog post by Monday"],
|
|
83
|
+
spoken:
|
|
84
|
+
"Everything is lined up for the fourth. A couple of small things are still open " +
|
|
85
|
+
"in checkout and Priya has both of them; nothing there is holding the release. " +
|
|
86
|
+
"Marketing needs the write-up at the start of the week.",
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A `stepFetch` for the LIVE legs, and it exists to work around a gap rather
|
|
91
|
+
* than to add anything.
|
|
92
|
+
*
|
|
93
|
+
* The eval engine publishes no `stepFetch`, so a step's HTTP falls back to
|
|
94
|
+
* `globalThis.fetch` — which cannot send this app's UPLOAD leg at all.
|
|
95
|
+
* `stepTranscribeUpload` streams a stored recording window by window, and an
|
|
96
|
+
* iterable body requires `duplex: "half"`; the published fetch adds it (see
|
|
97
|
+
* `sdk/step-fetch.ts`, which says so) and the fallback does not, so a live run
|
|
98
|
+
* dies on `RequestInit: duplex option is required when sending a body` before
|
|
99
|
+
* the provider is ever reached. `EvalWorkflowsOptions.stepFetch` is the
|
|
100
|
+
* documented seam for a host to supply its own, and this is the smallest one
|
|
101
|
+
* that works. Every scripted case below REPLACES it, publishing being a
|
|
102
|
+
* replacement.
|
|
103
|
+
*/
|
|
104
|
+
const liveStepFetch = (url: string, init: Record<string, unknown> = {}): Promise<Response> =>
|
|
105
|
+
globalThis.fetch(url, { ...init, duplex: "half" } as RequestInit);
|
|
106
|
+
|
|
107
|
+
/** Publish this app's own store, writable because the last step writes to it. */
|
|
108
|
+
function publish(bytes: Uint8Array, name: string, type: string) {
|
|
109
|
+
return installStubUploads({ [UPLOAD_ID]: { bytes, name, type } }, { writable: true });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Answer every leg of the run in memory: the three transcription calls, and the
|
|
114
|
+
* model.
|
|
115
|
+
*
|
|
116
|
+
* ONE fake, because publishing a `stepFetch` REPLACES — a flow that transcribes
|
|
117
|
+
* AND calls a model cannot install two, which is exactly what `otherwise` is
|
|
118
|
+
* for. The transcription half is the SDK's own fake rather than this file's
|
|
119
|
+
* hand-typed wire: it routes off the SDK's endpoint constants, so a case cannot
|
|
120
|
+
* pass because the fake and the step agree on a typo.
|
|
121
|
+
*/
|
|
122
|
+
function scriptProvider(options: { text?: string; pendingPolls?: number } = {}) {
|
|
123
|
+
return installStubTranscribe({
|
|
124
|
+
text: options.text ?? TRANSCRIPT,
|
|
125
|
+
durationSec: 42,
|
|
126
|
+
// Passed straight through rather than conditionally spread: the option
|
|
127
|
+
// already admits `undefined`, and `guard-invariants` rule 2 counts the
|
|
128
|
+
// spread.
|
|
129
|
+
pendingPolls: options.pendingPolls,
|
|
130
|
+
otherwise: (request) =>
|
|
131
|
+
request.url.includes("llm-gateway")
|
|
132
|
+
? { body: { choices: [{ message: { content: JSON.stringify(REPLY) } }] } }
|
|
133
|
+
: undefined,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
describeWorkflowEval(
|
|
138
|
+
agentDef,
|
|
139
|
+
(test) => {
|
|
140
|
+
test("the run's output NAMES a file that one step spoke and stored", async ({ app }) => {
|
|
141
|
+
// Scripted in both modes: the claim is that the id in the output is the id
|
|
142
|
+
// of the file in the store, and no provider can be asked to make that true.
|
|
143
|
+
// This is the case that catches speak-and-store coming apart — a run that
|
|
144
|
+
// returned bytes, or an id nothing wrote, or two ids because the synthesis
|
|
145
|
+
// and the store became two steps.
|
|
146
|
+
const uploads = publish(new Uint8Array(64), "standup.wav", "audio/wav");
|
|
147
|
+
const provider = scriptProvider();
|
|
148
|
+
const speech = installStubSpeech({ pcmBytes: 96_000 });
|
|
149
|
+
|
|
150
|
+
const run = await app.run(spokenSummary, { recording: UPLOAD_ID });
|
|
151
|
+
|
|
152
|
+
// The error FIRST, so a failed run names its own reason instead of
|
|
153
|
+
// reporting "expected 'failed' to be 'completed'".
|
|
154
|
+
expect(run.error).toBeUndefined();
|
|
155
|
+
expect(run.status).toBe("completed");
|
|
156
|
+
const output = run.output;
|
|
157
|
+
if (output === undefined) expect.fail("a completed run must carry an output");
|
|
158
|
+
|
|
159
|
+
// EXACTLY one write. Two would mean the synthesis and the store had come
|
|
160
|
+
// apart into two steps, which is the mistake this template exists to argue
|
|
161
|
+
// against — a step is journaled by its return value, so an id replays and
|
|
162
|
+
// bytes do not.
|
|
163
|
+
expect(uploads.writes).toHaveLength(1);
|
|
164
|
+
const written = uploads.writes[0];
|
|
165
|
+
if (written === undefined) expect.fail("the speaking step must have stored a file");
|
|
166
|
+
// The output carries the ID of that write, and an id is a string — not the
|
|
167
|
+
// audio. A run's output is read back as JSON.
|
|
168
|
+
expect(output.audio).toBe(written.id);
|
|
169
|
+
expect(typeof output.audio).toBe("string");
|
|
170
|
+
|
|
171
|
+
// And the bytes are really there, and are really a WAV: named and typed for
|
|
172
|
+
// a browser, because the byte route serves what it was given and nothing
|
|
173
|
+
// plays a file handed to it as octet-stream.
|
|
174
|
+
expect(written.name).toBe("summary.wav");
|
|
175
|
+
expect(written.type).toBe("audio/wav");
|
|
176
|
+
expect(written.bytes.byteLength).toBe(44 + 96_000);
|
|
177
|
+
expect(String.fromCharCode(...written.bytes.subarray(0, 4))).toBe("RIFF");
|
|
178
|
+
expect(String.fromCharCode(...written.bytes.subarray(8, 12))).toBe("WAVE");
|
|
179
|
+
// 96,000 bytes at 24 kHz mono 16-bit is two seconds, which is what the
|
|
180
|
+
// page prints next to the player.
|
|
181
|
+
expect(output.audioDurationMs).toBe(2000);
|
|
182
|
+
|
|
183
|
+
// The rest of the round trip, so a run that stored audio for the wrong text
|
|
184
|
+
// is not mistaken for a working one.
|
|
185
|
+
expect(output.source).toBe("standup.wav");
|
|
186
|
+
expect(output.durationMs).toBe(42_000);
|
|
187
|
+
expect(output.transcript).toBe(TRANSCRIPT);
|
|
188
|
+
expect(output.points).toHaveLength(3);
|
|
189
|
+
expect(speech.calls).toHaveLength(1);
|
|
190
|
+
|
|
191
|
+
// Four legs, four narrated lines, in order — which is what a page watching
|
|
192
|
+
// the run renders.
|
|
193
|
+
expect(run.reported[0]).toMatch(/^Uploading standup\.wav /);
|
|
194
|
+
expect(run.reported).toContain("Summarizing the transcript.");
|
|
195
|
+
expect(run.reported.at(-1)).toMatch(/^Recorded a 2s summary in \w+'s voice\.$/);
|
|
196
|
+
// The job finished on its first poll, so no durable wait was asked for.
|
|
197
|
+
expect(run.slept).toEqual([]);
|
|
198
|
+
// One upload of the recording, and one submit — the split that exists so a
|
|
199
|
+
// fault in the submit does not re-upload the file.
|
|
200
|
+
expect(provider.calls.filter((call) => call.leg === "upload")).toHaveLength(1);
|
|
201
|
+
expect(provider.calls.filter((call) => call.leg === "submit")).toHaveLength(1);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("the voice reads the SCRIPT the model was asked for, not the points", async ({ app }) => {
|
|
205
|
+
// Scripted in both modes: what a live voice says is not evidence about
|
|
206
|
+
// which string it was handed. This is the case that catches the template's
|
|
207
|
+
// central prompt decision regressing — synthesize the bullet list and you
|
|
208
|
+
// get a voice reading "one. two. three." with no connective tissue.
|
|
209
|
+
publish(new Uint8Array(64), "standup.wav", "audio/wav");
|
|
210
|
+
const provider = scriptProvider();
|
|
211
|
+
const speech = installStubSpeech();
|
|
212
|
+
|
|
213
|
+
const run = await app.run(spokenSummary, { recording: UPLOAD_ID, voice: "michael" });
|
|
214
|
+
|
|
215
|
+
expect(run.error).toBeUndefined();
|
|
216
|
+
const spokenText = speech.calls[0]?.text;
|
|
217
|
+
expect(spokenText).toBe(REPLY.spoken);
|
|
218
|
+
// The script and the points share no wording, so this is the assertion:
|
|
219
|
+
// whatever was spoken, it was not the list.
|
|
220
|
+
for (const point of REPLY.points) expect(spokenText).not.toContain(point);
|
|
221
|
+
// The form's choice really reaches the synthesizer.
|
|
222
|
+
expect(speech.calls[0]?.voice).toBe("michael");
|
|
223
|
+
// Both shapes survive to the output — one to read, one that was heard.
|
|
224
|
+
expect(run.output?.points).toEqual(REPLY.points);
|
|
225
|
+
expect(run.output?.spoken).toBe(REPLY.spoken);
|
|
226
|
+
|
|
227
|
+
// And the model was ASKED for both, over the transcript it was given. A
|
|
228
|
+
// prompt that stopped asking for a script is how the field goes missing.
|
|
229
|
+
const prompt = String(provider.calls.find((call) => call.leg === "other")?.body ?? "");
|
|
230
|
+
expect(prompt).toContain("READ ALOUD");
|
|
231
|
+
expect(prompt).toContain("The launch is on for Tuesday the fourth");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("a recording with no speech stops before the model and the voice", async ({ app }) => {
|
|
235
|
+
// Scripted in both modes, and it costs nothing live either way: silence
|
|
236
|
+
// transcribes SUCCESSFULLY to nothing, so without a terminal failure here
|
|
237
|
+
// the run would go on to summarize no words and store half a second of
|
|
238
|
+
// audio — a green run with an empty product.
|
|
239
|
+
const uploads = publish(new Uint8Array(64), "silence.wav", "audio/wav");
|
|
240
|
+
const provider = scriptProvider({ text: " " });
|
|
241
|
+
const speech = installStubSpeech();
|
|
242
|
+
|
|
243
|
+
const run = await app.run(spokenSummary, { recording: UPLOAD_ID });
|
|
244
|
+
|
|
245
|
+
expect(run.status).toBe("failed");
|
|
246
|
+
expect(run.error).toMatch(/no speech in that recording/i);
|
|
247
|
+
expect(run.output).toBeUndefined();
|
|
248
|
+
// Nothing was summarized and nothing was spoken, which is the half that
|
|
249
|
+
// makes this more than an error-message assertion.
|
|
250
|
+
expect(provider.calls.filter((call) => call.leg === "other")).toEqual([]);
|
|
251
|
+
expect(speech.calls).toEqual([]);
|
|
252
|
+
expect(uploads.writes).toEqual([]);
|
|
253
|
+
expect(run.reported).not.toContain("Summarizing the transcript.");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("an unfinished job is waited out with a DURABLE sleep, not a busy loop", async ({
|
|
257
|
+
app,
|
|
258
|
+
}) => {
|
|
259
|
+
// Scripted in both modes: a live job cannot be asked to stay queued for
|
|
260
|
+
// exactly two polls, and this is the one place the poll loop's shape is
|
|
261
|
+
// visible — one submit, one poll per round, and a recorded wait between
|
|
262
|
+
// them. A loop that re-submitted, or one that spun with no wait, both
|
|
263
|
+
// produce a correct transcript and a wrong bill.
|
|
264
|
+
publish(new Uint8Array(64), "standup.wav", "audio/wav");
|
|
265
|
+
const provider = scriptProvider({ pendingPolls: 2 });
|
|
266
|
+
installStubSpeech();
|
|
267
|
+
|
|
268
|
+
const run = await app.run(spokenSummary, { recording: UPLOAD_ID });
|
|
269
|
+
|
|
270
|
+
expect(run.error).toBeUndefined();
|
|
271
|
+
expect(run.output?.transcript).toBe(TRANSCRIPT);
|
|
272
|
+
// Two waits for three polls: asked for and — this being an eval rather
|
|
273
|
+
// than a deployment — recorded rather than taken.
|
|
274
|
+
expect(run.slept).toEqual([{ duration: POLL_INTERVAL }, { duration: POLL_INTERVAL }]);
|
|
275
|
+
expect(provider.calls.filter((call) => call.leg === "poll")).toHaveLength(3);
|
|
276
|
+
// The expensive half happened ONCE, which is the whole reason the upload
|
|
277
|
+
// and the submit are separate steps.
|
|
278
|
+
expect(provider.calls.filter((call) => call.leg === "upload")).toHaveLength(1);
|
|
279
|
+
expect(provider.calls.filter((call) => call.leg === "submit")).toHaveLength(1);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test(
|
|
283
|
+
"really transcribes and summarizes a real recording",
|
|
284
|
+
async ({ app }) => {
|
|
285
|
+
// LIVE ONLY, and the one case here that measures rather than checks
|
|
286
|
+
// wiring. Three things have to be real for it to pass: the file has to
|
|
287
|
+
// stream to the provider, the transcript has to be of THAT recording, and
|
|
288
|
+
// the model has to write a script from that transcript.
|
|
289
|
+
//
|
|
290
|
+
// The VOICE is faked even here, and that is a harness limit rather than a
|
|
291
|
+
// choice — `stepSpeak` reads a published synthesizer, the eval engine
|
|
292
|
+
// publishes none, and no real one is exported to pass it. So what this
|
|
293
|
+
// case does NOT claim is that the audio is audible; the round trip's
|
|
294
|
+
// structure (one write, an id in the output, real WAV framing) is what
|
|
295
|
+
// the first case pins, in memory, where it is a fact rather than a hope.
|
|
296
|
+
const speech = installStubSpeech({ pcmBytes: 48_000 });
|
|
297
|
+
const response = await fetch(LIVE_RECORDING);
|
|
298
|
+
expect(response.ok).toBe(true);
|
|
299
|
+
const mp3 = new Uint8Array(await response.arrayBuffer());
|
|
300
|
+
const uploads = publish(mp3, "wildfires.mp3", "audio/mpeg");
|
|
301
|
+
|
|
302
|
+
const run = await app.run(spokenSummary, { recording: UPLOAD_ID });
|
|
303
|
+
|
|
304
|
+
expect(run.error).toBeUndefined();
|
|
305
|
+
expect(run.status).toBe("completed");
|
|
306
|
+
const output = run.output;
|
|
307
|
+
if (output === undefined) expect.fail("a completed run must carry an output");
|
|
308
|
+
|
|
309
|
+
// The transcript is of THIS recording — a news segment about smoke from
|
|
310
|
+
// Canadian wildfires reaching the US east coast.
|
|
311
|
+
expect(output.transcript).toMatch(/wildfire/i);
|
|
312
|
+
expect(output.transcript).toMatch(/canada/i);
|
|
313
|
+
expect(output.words).toBeGreaterThan(400);
|
|
314
|
+
// The FILENAME a reader sees is the one they uploaded, not the opaque id.
|
|
315
|
+
expect(output.source).toBe("wildfires.mp3");
|
|
316
|
+
expect(output.durationMs).toBeGreaterThan(250_000);
|
|
317
|
+
|
|
318
|
+
// The summary is of the transcript, not of summarizing in general.
|
|
319
|
+
const written = `${output.headline} ${output.points.join(" ")} ${output.spoken}`;
|
|
320
|
+
expect(written).toMatch(/smoke|wildfire|air/i);
|
|
321
|
+
expect(output.points.length).toBeGreaterThan(0);
|
|
322
|
+
expect(output.points.length).toBeLessThanOrEqual(4);
|
|
323
|
+
// A SCRIPT rather than a list: sentences, no bullet markers, and long
|
|
324
|
+
// enough that a voice reading it has something to say. This is the
|
|
325
|
+
// template's central prompt decision, measured against a real model.
|
|
326
|
+
expect(output.spoken).toMatch(/[.!?]/);
|
|
327
|
+
expect(output.spoken).not.toMatch(/^\s*[-*\u2022]/m);
|
|
328
|
+
expect(output.spoken.length).toBeGreaterThan(80);
|
|
329
|
+
// And it is the SCRIPT that was spoken, not the points a page renders.
|
|
330
|
+
expect(speech.calls[0]?.text).toBe(output.spoken);
|
|
331
|
+
|
|
332
|
+
// The store really holds what the output names.
|
|
333
|
+
expect(uploads.writes).toHaveLength(1);
|
|
334
|
+
const stored = uploads.read(output.audio);
|
|
335
|
+
if (stored === undefined) expect.fail("the run's `audio` id must name a stored file");
|
|
336
|
+
expect(String.fromCharCode(...stored.bytes.subarray(0, 4))).toBe("RIFF");
|
|
337
|
+
},
|
|
338
|
+
{ live: true },
|
|
339
|
+
);
|
|
340
|
+
},
|
|
341
|
+
{ stepFetch: liveStepFetch },
|
|
342
|
+
);
|
|
@@ -17,32 +17,31 @@
|
|
|
17
17
|
* its return value, and audio in one is megabytes replayed on every resume.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
import { readUpload, uploadInfo } from "@alexkroman1/aai/step";
|
|
21
|
+
import {
|
|
22
|
+
installStubGateway,
|
|
23
|
+
installStubReporter,
|
|
24
|
+
installStubSpeech,
|
|
25
|
+
installStubTranscribe,
|
|
26
|
+
installStubUploads,
|
|
27
|
+
} from "@alexkroman1/aai/testing/vitest";
|
|
28
|
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
24
29
|
import { FatalError, RetryableError } from "workflow";
|
|
25
30
|
import agentDef, { spokenSummary } from "./agent.ts";
|
|
26
31
|
import { speak, spokenSummaryFlow, summarize } from "./workflows/summarize.ts";
|
|
27
|
-
import {
|
|
32
|
+
import { createJob, pollTranscript, uploadToProvider } from "./workflows/transcribe.ts";
|
|
28
33
|
|
|
29
34
|
/** The id every spec below uploads under. */
|
|
30
35
|
const UPLOAD_ID = "upl_test";
|
|
31
36
|
|
|
32
|
-
/** Slots left published reach the next file, so every one is released here. */
|
|
33
|
-
const restores: (() => void)[] = [];
|
|
34
|
-
afterEach(() => {
|
|
35
|
-
while (restores.length > 0) restores.pop()?.();
|
|
36
|
-
});
|
|
37
|
-
|
|
38
37
|
beforeEach(() => {
|
|
39
38
|
// WRITABLE, because this app's whole second half stores a file — and it is
|
|
40
39
|
// opt-in precisely so a step that wrote one nobody meant it to would fail.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
// `install*` rather than `stub*`: the fake registers its own `onTestFinished`,
|
|
41
|
+
// which is what replaced the hand-kept restore registry this file used to hold.
|
|
42
|
+
installStubUploads(
|
|
43
|
+
{ [UPLOAD_ID]: { bytes: new Uint8Array(64), name: "standup.wav", type: "audio/wav" } },
|
|
44
|
+
{ writable: true },
|
|
46
45
|
);
|
|
47
46
|
// The step env, which is where `requireStepEnv` and `stepSpeak` read the key.
|
|
48
47
|
// `vi.stubEnv` rather than an assignment: `unstubEnvs` undoes it before every
|
|
@@ -84,40 +83,38 @@ describe("the declaration", () => {
|
|
|
84
83
|
|
|
85
84
|
describe("transcribing", () => {
|
|
86
85
|
test("streams the stored recording to the provider and keeps the URL it answered", async () => {
|
|
87
|
-
const
|
|
88
|
-
|
|
86
|
+
const provider = installStubTranscribe({ audioUrl: "https://cdn/aai/1" });
|
|
87
|
+
installStubReporter();
|
|
89
88
|
|
|
90
89
|
await expect(uploadToProvider(UPLOAD_ID)).resolves.toEqual({
|
|
91
90
|
audioUrl: "https://cdn/aai/1",
|
|
92
91
|
});
|
|
93
|
-
expect(
|
|
94
|
-
expect(fetches.calls[0]?.headers.Authorization).toBe("test-key");
|
|
92
|
+
expect(provider.calls[0]?.leg).toBe("upload");
|
|
95
93
|
// The bytes really went, and they went as the file rather than as JSON.
|
|
96
|
-
expect(
|
|
94
|
+
expect(provider.calls[0]?.body?.length).toBe(64);
|
|
97
95
|
});
|
|
98
96
|
|
|
99
97
|
test("a 429 from the provider is RETRYABLE and a 400 is not", async () => {
|
|
100
|
-
const first =
|
|
101
|
-
|
|
98
|
+
const first = installStubTranscribe({ failure: { status: 429, message: "slow down" } });
|
|
99
|
+
installStubReporter();
|
|
102
100
|
await expect(createJob("https://cdn/aai/1")).rejects.toBeInstanceOf(RetryableError);
|
|
101
|
+
// Unpublished by hand: this is a boundary WITHIN one test, which the
|
|
102
|
+
// per-test auto-restore cannot give.
|
|
103
103
|
first.restore();
|
|
104
104
|
|
|
105
|
-
|
|
106
|
-
restores.push(second.restore);
|
|
105
|
+
installStubTranscribe({ failure: { status: 400, message: "bad model" } });
|
|
107
106
|
await expect(createJob("https://cdn/aai/1")).rejects.toBeInstanceOf(FatalError);
|
|
108
107
|
});
|
|
109
108
|
|
|
110
109
|
test("a job the provider gave up on is FATAL — no number of polls changes it", async () => {
|
|
111
|
-
|
|
112
|
-
stubStepFetch(() => ({ body: { status: "error", error: "corrupt audio" } })).restore,
|
|
113
|
-
);
|
|
110
|
+
installStubTranscribe({ jobError: "corrupt audio" });
|
|
114
111
|
|
|
115
112
|
await expect(pollTranscript(UPLOAD_ID, "t_1")).rejects.toThrow("corrupt audio");
|
|
116
113
|
await expect(pollTranscript(UPLOAD_ID, "t_1")).rejects.toBeInstanceOf(FatalError);
|
|
117
114
|
});
|
|
118
115
|
|
|
119
116
|
test("`done` is decided here, so the body never reads a provider's vocabulary", async () => {
|
|
120
|
-
|
|
117
|
+
installStubTranscribe({ pendingPolls: 1 });
|
|
121
118
|
|
|
122
119
|
await expect(pollTranscript(UPLOAD_ID, "t_1")).resolves.toEqual({ done: false });
|
|
123
120
|
});
|
|
@@ -125,26 +122,21 @@ describe("transcribing", () => {
|
|
|
125
122
|
test("a finished poll carries the transcript, named by the FILENAME", async () => {
|
|
126
123
|
// ONE request, not two: this used to poll for a status and then fetch the
|
|
127
124
|
// identical URL again for the text the poll already had in its hand.
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
}));
|
|
131
|
-
restores.push(fetches.restore, stubReporter().restore);
|
|
125
|
+
const provider = installStubTranscribe({ text: " we shipped it ", durationSec: 12.4 });
|
|
126
|
+
installStubReporter();
|
|
132
127
|
|
|
133
128
|
await expect(pollTranscript(UPLOAD_ID, "t_1")).resolves.toEqual({
|
|
134
129
|
done: true,
|
|
135
130
|
transcript: { source: "standup.wav", durationMs: 12_400, text: "we shipped it" },
|
|
136
131
|
});
|
|
137
|
-
expect(
|
|
132
|
+
expect(provider.calls).toHaveLength(1);
|
|
138
133
|
});
|
|
139
134
|
|
|
140
135
|
test("a recording of silence is FATAL rather than an empty summary", async () => {
|
|
141
136
|
// The failure this template is most likely to meet: silence transcribes
|
|
142
137
|
// successfully to nothing, and everything downstream would then be asked to
|
|
143
138
|
// summarize and speak no words at all.
|
|
144
|
-
|
|
145
|
-
stubStepFetch(() => ({ body: { status: "completed", text: " ", audio_duration: 3 } }))
|
|
146
|
-
.restore,
|
|
147
|
-
);
|
|
139
|
+
installStubTranscribe({ text: " ", durationSec: 3 });
|
|
148
140
|
|
|
149
141
|
await expect(pollTranscript(UPLOAD_ID, "t_1")).rejects.toThrow("no speech in that recording");
|
|
150
142
|
await expect(pollTranscript(UPLOAD_ID, "t_1")).rejects.toBeInstanceOf(FatalError);
|
|
@@ -160,7 +152,7 @@ describe("summarizing", () => {
|
|
|
160
152
|
spoken: "The launch is on for Tuesday, with two bugs still open.",
|
|
161
153
|
}),
|
|
162
154
|
);
|
|
163
|
-
|
|
155
|
+
installStubReporter();
|
|
164
156
|
|
|
165
157
|
const summary = await summarize("we ship tuesday");
|
|
166
158
|
|
|
@@ -181,14 +173,14 @@ describe("summarizing", () => {
|
|
|
181
173
|
spoken: "Several things happened.",
|
|
182
174
|
}),
|
|
183
175
|
);
|
|
184
|
-
|
|
176
|
+
installStubReporter();
|
|
185
177
|
|
|
186
178
|
expect((await summarize("…")).points).toHaveLength(4);
|
|
187
179
|
});
|
|
188
180
|
|
|
189
181
|
test("a reply with no spoken script FAILS rather than defaulting to silence", async () => {
|
|
190
182
|
installStubGateway(JSON.stringify({ headline: "Launch is on", points: ["Ship Tuesday"] }));
|
|
191
|
-
|
|
183
|
+
installStubReporter();
|
|
192
184
|
|
|
193
185
|
await expect(summarize("…")).rejects.toThrow(/did not match the shape/);
|
|
194
186
|
});
|
|
@@ -196,8 +188,8 @@ describe("summarizing", () => {
|
|
|
196
188
|
|
|
197
189
|
describe("speaking", () => {
|
|
198
190
|
test("stores a WAV and returns its ID — never the bytes", async () => {
|
|
199
|
-
const speech =
|
|
200
|
-
|
|
191
|
+
const speech = installStubSpeech({ pcmBytes: 48_000 });
|
|
192
|
+
installStubReporter();
|
|
201
193
|
|
|
202
194
|
const spoken = await speak("The launch is on for Tuesday.");
|
|
203
195
|
|
|
@@ -208,7 +200,8 @@ describe("speaking", () => {
|
|
|
208
200
|
});
|
|
209
201
|
|
|
210
202
|
test("what it stored is a real WAV, named and typed for the browser", async () => {
|
|
211
|
-
|
|
203
|
+
installStubSpeech({ pcmBytes: 4000 });
|
|
204
|
+
installStubReporter();
|
|
212
205
|
|
|
213
206
|
const { audio } = await speak("Hello.");
|
|
214
207
|
|
|
@@ -225,8 +218,8 @@ describe("speaking", () => {
|
|
|
225
218
|
});
|
|
226
219
|
|
|
227
220
|
test("passes a chosen voice through, and omits it entirely when none was chosen", async () => {
|
|
228
|
-
const speech =
|
|
229
|
-
|
|
221
|
+
const speech = installStubSpeech();
|
|
222
|
+
installStubReporter();
|
|
230
223
|
|
|
231
224
|
await speak("Hello.", "michael");
|
|
232
225
|
await speak("Hello.");
|
|
@@ -237,13 +230,6 @@ describe("speaking", () => {
|
|
|
237
230
|
});
|
|
238
231
|
});
|
|
239
232
|
|
|
240
|
-
describe("countWords", () => {
|
|
241
|
-
test("counts words rather than characters, and answers 0 for nothing", () => {
|
|
242
|
-
expect(countWords("we shipped it on tuesday")).toBe(5);
|
|
243
|
-
expect(countWords(" ")).toBe(0);
|
|
244
|
-
});
|
|
245
|
-
});
|
|
246
|
-
|
|
247
233
|
describe("the whole run", () => {
|
|
248
234
|
/**
|
|
249
235
|
* Answer every leg's HTTP, so the BODY can be driven end to end.
|
|
@@ -261,38 +247,35 @@ describe("the whole run", () => {
|
|
|
261
247
|
* **The model call goes through this too, not `installStubGateway`.** A
|
|
262
248
|
* published `stepFetch` is what `stepGenerate` makes its request with, so a
|
|
263
249
|
* global-fetch stub is never reached once one exists — which is exactly the
|
|
264
|
-
* point `
|
|
250
|
+
* point a published `stepFetch` exists to make, and exactly why
|
|
251
|
+
* `stubTranscribe` takes an `otherwise`: publishing REPLACES, so a flow that
|
|
252
|
+
* transcribes AND calls a model cannot install two fakes.
|
|
253
|
+
*
|
|
254
|
+
* The three transcription legs are the SDK's fake rather than this file's
|
|
255
|
+
* hand-typed wire — it routes them off the SDK's own endpoint constants, so a
|
|
256
|
+
* spec cannot pass because the fake and the step agree on a typo.
|
|
265
257
|
*/
|
|
266
258
|
function stubProvider(reply: { headline: string; points: string[]; spoken: string }) {
|
|
267
|
-
return
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
return {
|
|
277
|
-
body: {
|
|
278
|
-
status: "completed",
|
|
279
|
-
text: "we ship tuesday and two bugs are left",
|
|
280
|
-
audio_duration: 42,
|
|
281
|
-
},
|
|
282
|
-
};
|
|
259
|
+
return installStubTranscribe({
|
|
260
|
+
audioUrl: "https://cdn/aai/1",
|
|
261
|
+
jobIdPrefix: "t_",
|
|
262
|
+
text: "we ship tuesday and two bugs are left",
|
|
263
|
+
durationSec: 42,
|
|
264
|
+
otherwise: (request) =>
|
|
265
|
+
request.url.includes("llm-gateway")
|
|
266
|
+
? { body: { choices: [{ message: { content: JSON.stringify(reply) } }] } }
|
|
267
|
+
: undefined,
|
|
283
268
|
});
|
|
284
269
|
}
|
|
285
270
|
|
|
286
271
|
test("transcribes, summarizes, speaks, and reports the file it made", async () => {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
stubSpeech().restore,
|
|
295
|
-
);
|
|
272
|
+
stubProvider({
|
|
273
|
+
headline: "Launch is on",
|
|
274
|
+
points: ["Ship Tuesday"],
|
|
275
|
+
spoken: "The launch is on for Tuesday.",
|
|
276
|
+
});
|
|
277
|
+
installStubReporter();
|
|
278
|
+
installStubSpeech();
|
|
296
279
|
|
|
297
280
|
const summary = await spokenSummaryFlow({ recording: UPLOAD_ID });
|
|
298
281
|
|
|
@@ -312,13 +295,9 @@ describe("the whole run", () => {
|
|
|
312
295
|
});
|
|
313
296
|
|
|
314
297
|
test("the voice the form chose reaches the synthesizer", async () => {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
.restore,
|
|
319
|
-
stubReporter().restore,
|
|
320
|
-
speech.restore,
|
|
321
|
-
);
|
|
298
|
+
stubProvider({ headline: "Launch is on", points: ["Ship Tuesday"], spoken: "Spoken." });
|
|
299
|
+
installStubReporter();
|
|
300
|
+
const speech = installStubSpeech();
|
|
322
301
|
|
|
323
302
|
await spokenSummaryFlow({ recording: UPLOAD_ID, voice: "michael" });
|
|
324
303
|
|
|
@@ -326,17 +305,9 @@ describe("the whole run", () => {
|
|
|
326
305
|
});
|
|
327
306
|
|
|
328
307
|
test("a recording the provider gave up on fails the run rather than half-summarizing", async () => {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
? { body: { upload_url: "https://cdn/aai/1" } }
|
|
333
|
-
: request.method === "POST"
|
|
334
|
-
? { body: { id: "t_1" } }
|
|
335
|
-
: { body: { status: "error", error: "corrupt audio" } },
|
|
336
|
-
).restore,
|
|
337
|
-
stubReporter().restore,
|
|
338
|
-
stubSpeech().restore,
|
|
339
|
-
);
|
|
308
|
+
installStubTranscribe({ jobError: "corrupt audio" });
|
|
309
|
+
installStubReporter();
|
|
310
|
+
installStubSpeech();
|
|
340
311
|
|
|
341
312
|
await expect(spokenSummaryFlow({ recording: UPLOAD_ID })).rejects.toThrow("corrupt audio");
|
|
342
313
|
});
|