@alexkroman1/aai-cli 6.10.1 → 7.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/dist/{_agent-DpH2pBJd.mjs → _agent-BTwZJu4P.mjs} +2 -2
- package/dist/{_bundler-C2BDwU7r.mjs → _bundler-B4RqNF5Z.mjs} +2 -2
- package/dist/_dev-env.d.ts +22 -2
- package/dist/{_dev-server-LLLWnFBs.mjs → _dev-server-CiKFH9qw.mjs} +38 -47
- package/dist/_dev-server.d.ts +0 -17
- package/dist/{_init-C57bWAQ8.mjs → _init-CE4HKdgV.mjs} +3 -2
- package/dist/{_preflight-BtfaYtbE.mjs → _preflight-Co1G9Jww.mjs} +1 -1
- package/dist/_preflight.d.ts +1 -1
- package/dist/{_slug-api-BxrsJXVI.mjs → _slug-api-nRFaBEPJ.mjs} +2 -2
- package/dist/{_studio-U3jhKlA3.mjs → _studio-DR5yzZ1T.mjs} +2 -1
- package/dist/{_templates-Dxb_P2Wz.mjs → _templates-FI3xPEMj.mjs} +1 -1
- package/dist/_workflow-scan.d.ts +108 -0
- package/dist/{build-Dfza2pRI.mjs → build-6jfaakgc.mjs} +5 -2
- package/dist/cli.mjs +30 -25
- 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-DvYeD8ia.mjs} +5 -5
- package/dist/{dev-Uq5ujP8a.mjs → dev-B59JIfWO.mjs} +1 -1
- package/dist/{eject-31gjtaHF.mjs → eject-C9WJyyr2.mjs} +1 -1
- package/dist/{init-l6xfU4xX.mjs → init-CpJVjEg0.mjs} +3 -3
- package/dist/{login-C71-qz8F.mjs → login-BBuM1sxH.mjs} +3 -4
- package/dist/{logs-BWVGSjLR.mjs → logs-9q-psa4q.mjs} +2 -2
- package/dist/project-config.mjs +1 -1
- package/dist/scaffold/CLAUDE.md +93 -14
- package/dist/scaffold/package.json +5 -3
- package/dist/scaffold/server.mjs +13 -4
- package/dist/scaffold/vite.config.ts +1 -1
- package/dist/{secret-BuMuFR4B.mjs → secret-CVvSLIDV.mjs} +2 -2
- package/dist/{storage-DzRZ-eCw.mjs → storage-BvUrnvM3.mjs} +2 -2
- package/dist/{studio-BCNUpDgP.mjs → studio--MUV0cid.mjs} +4 -4
- package/dist/templates/call-audit/agent.test.ts +818 -0
- package/dist/templates/call-audit/agent.ts +171 -0
- package/dist/templates/call-audit/client.tsx +198 -0
- package/dist/templates/call-audit/workflows/audit.ts +298 -0
- package/dist/templates/call-audit/workflows/ingest.ts +251 -0
- package/dist/templates/call-audit/workflows/media.ts +637 -0
- package/dist/templates/call-audit/workflows/summarize.ts +201 -0
- package/dist/templates/call-audit/workflows/sync-api.ts +44 -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.test.ts +17 -14
- package/dist/templates/health-assistant/agent.test.ts +22 -4
- package/dist/templates/infocom-adventure/agent.test.ts +4 -6
- package/dist/templates/infocom-adventure/client.tsx +246 -164
- 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/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/pizza-ordering/agent.test.ts +27 -27
- package/dist/templates/pizza-ordering/client.tsx +9 -26
- 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.test.ts +746 -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 +411 -0
- package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
- package/dist/templates/podcast-digest/workflows/slack.ts +209 -0
- package/dist/templates/recap-workflow/agent.test.ts +107 -96
- 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 +48 -41
- 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.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/agent.test.ts +26 -23
- package/dist/templates/retail/client.tsx +226 -117
- package/dist/templates/retail/registry.test.ts +38 -6
- package/dist/templates/retail/store.test.ts +82 -15
- package/dist/templates/retail/store.ts +174 -47
- package/dist/templates/retail/system-prompt.md +11 -2
- package/dist/templates/retail/tools/cancel_pending_order.ts +2 -2
- package/dist/templates/retail/tools/exchange_delivered_order_items.ts +2 -2
- 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 +2 -2
- package/dist/templates/retail/tools/modify_pending_order_items.ts +2 -3
- package/dist/templates/retail/tools/modify_pending_order_payment.ts +2 -2
- package/dist/templates/retail/tools/modify_user_address.ts +2 -2
- package/dist/templates/retail/tools/return_delivered_order_items.ts +2 -2
- package/dist/templates/retail/tools/transfer_to_human_agents.ts +8 -4
- 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.test.ts +314 -0
- package/dist/templates/spoken-summary/agent.ts +155 -0
- package/dist/templates/spoken-summary/client.tsx +185 -0
- package/dist/templates/spoken-summary/workflows/summarize.ts +237 -0
- package/dist/templates/spoken-summary/workflows/transcribe.ts +138 -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/tools/answer_question.ts +17 -4
- package/dist/templates/transcription-workflow/agent.test.ts +246 -123
- package/dist/templates/transcription-workflow/agent.ts +21 -7
- package/dist/templates/transcription-workflow/client.tsx +17 -42
- package/dist/templates/transcription-workflow/workflows/batch.ts +79 -180
- package/dist/templates/transcription-workflow/workflows/normalize.ts +247 -0
- package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
- package/dist/templates/transcription-workflow/workflows/stream.ts +10 -9
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +26 -94
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +55 -40
- package/dist/templates/transcription-workflow/workflows/wav.ts +31 -31
- package/dist/templates/travel-concierge/agent.test.ts +64 -33
- 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 +70 -3
- package/dist/templates/travel-concierge/tools/book_car_rental.ts +2 -2
- package/dist/templates/travel-concierge/tools/book_excursion.ts +2 -2
- package/dist/templates/travel-concierge/tools/book_hotel.ts +2 -2
- package/dist/templates/travel-concierge/tools/cancel_ticket.ts +2 -2
- package/dist/templates/travel-concierge/tools/update_ticket.ts +2 -2
- package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-COxnqstQ.mjs} +221 -3
- package/dist/worker-bundler.mjs +1 -1
- package/dist/{workflow-CFpxOFfQ.mjs → workflow-D2AQf2Pl.mjs} +28 -1
- package/dist/workflow-bundler.d.ts +6 -1
- package/dist/workflow.d.ts +1 -1
- package/package.json +5 -4
- 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
|
@@ -1,17 +1,26 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { gameSlot, saveSlotKey, saveSlotParam, saveState } from "../shared.ts";
|
|
2
|
+
import { gameSlot, saveSlotKey, saveSlotParam, saveState, storyFlow } from "../shared.ts";
|
|
3
3
|
|
|
4
4
|
// Requires storage — `aai storage enable` (or DATABASE_URL in .env under
|
|
5
5
|
// `aai dev`); the rest of the game works without it.
|
|
6
6
|
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Gated on `playing` or `gameOver` — i.e. anything but `awaitingSetup`.
|
|
9
|
+
*
|
|
10
|
+
* Saving before a character exists writes an empty campaign under a slot name,
|
|
11
|
+
* and `load_game` would then cheerfully restore it over a real game. That is a
|
|
12
|
+
* position, so it is a `when`.
|
|
13
|
+
*
|
|
14
|
+
* The body AWAITS, which a flow tool allows: only `slot.updateTool` must be
|
|
15
|
+
* synchronous, because its draft is stored when it returns. What a save needs is
|
|
16
|
+
* the frozen value, and `gameSlot.get` is what hands it over.
|
|
17
|
+
*/
|
|
18
|
+
export default storyFlow.tool({
|
|
12
19
|
description: "Save current game to persistent storage.",
|
|
13
20
|
inputSchema: z.object({ slot: saveSlotParam }),
|
|
14
|
-
|
|
21
|
+
when: ["playing", "gameOver"],
|
|
22
|
+
async execute(args, ctx) {
|
|
23
|
+
const game = gameSlot.get(ctx);
|
|
15
24
|
await saveState(ctx, saveSlotKey(args.slot), game);
|
|
16
25
|
return {
|
|
17
26
|
saved: true,
|
|
@@ -14,10 +14,24 @@ import {
|
|
|
14
14
|
makeNpc,
|
|
15
15
|
shuffle,
|
|
16
16
|
stateSummary,
|
|
17
|
+
storyFlow,
|
|
17
18
|
TIME_PHASES,
|
|
18
19
|
TONES,
|
|
19
20
|
} from "../shared.ts";
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* **Not a `storyFlow.tool`, deliberately.** Setting up is legal in every state —
|
|
24
|
+
* a player may start over at any point, an ended story included — so a `when`
|
|
25
|
+
* listing all of them would be a gate that gates nothing. It drives the flow
|
|
26
|
+
* itself, which is what `dialog.reset` and `dialog.send` are public for, and reports
|
|
27
|
+
* the position it landed in so the narrator reads what the opening turn expects.
|
|
28
|
+
*
|
|
29
|
+
* **`reset` before `send`, because a restart is not a transition.** This body
|
|
30
|
+
* replaces the campaign with a pristine `DEFAULT_STATE`; resetting the flow is
|
|
31
|
+
* the same statement on the other side, and it is what makes starting over work
|
|
32
|
+
* from `gameOver` — a final state delivers no events, so a `SETUP` transition
|
|
33
|
+
* out of it would be dead config.
|
|
34
|
+
*/
|
|
21
35
|
export default tool({
|
|
22
36
|
description:
|
|
23
37
|
"Set up the entire game in one call. Starts a completely fresh game (any previous unsaved game is replaced), generates stats, initializes state, and marks the game as ready. After this returns, just narrate the opening scene. No need to call update_state — everything is already done.",
|
|
@@ -186,12 +200,16 @@ export default tool({
|
|
|
186
200
|
storyComplete: false,
|
|
187
201
|
};
|
|
188
202
|
|
|
189
|
-
// Mark initialized
|
|
203
|
+
// Mark initialized. `initialized` is the CLIENT's render flag; the flow is
|
|
204
|
+
// what gates the tools, and `SETUP` is what puts it in `playing` — so the
|
|
205
|
+
// roll tools become available in the same call that gives them stats to
|
|
206
|
+
// roll against.
|
|
190
207
|
state.initialized = true;
|
|
191
|
-
state.phase = "playing";
|
|
192
208
|
state.sceneCount = 1;
|
|
193
209
|
|
|
194
210
|
gameSlot.set(ctx, state);
|
|
211
|
+
storyFlow.reset(ctx);
|
|
212
|
+
const at = storyFlow.send(ctx, { type: "SETUP" });
|
|
195
213
|
|
|
196
214
|
return {
|
|
197
215
|
success: true,
|
|
@@ -201,6 +219,8 @@ export default tool({
|
|
|
201
219
|
archetypeLabel: ARCHETYPES[args.archetype as keyof typeof ARCHETYPES] || args.archetype,
|
|
202
220
|
openingSituation: args.openingSituation,
|
|
203
221
|
creativitySeed: creativitySeed(),
|
|
222
|
+
// The position spread verbatim — the same keys every gated tool reports.
|
|
223
|
+
...at,
|
|
204
224
|
// The real saved state — never hardcoded values
|
|
205
225
|
...stateSummary(state),
|
|
206
226
|
};
|
|
@@ -15,13 +15,23 @@ import {
|
|
|
15
15
|
makeNpc,
|
|
16
16
|
nextSeqId,
|
|
17
17
|
stateSummary,
|
|
18
|
+
storyFlow,
|
|
18
19
|
TIME_PHASES,
|
|
19
|
-
updateCrisisFlags,
|
|
20
20
|
} from "../shared.ts";
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Gated on `playing`: there is nothing to sync before a character exists (that
|
|
24
|
+
* is `setup_character`'s whole job, and the prompt says not to call this after
|
|
25
|
+
* it), and nothing to sync after the story has ended.
|
|
26
|
+
*
|
|
27
|
+
* It sends `SETTLED`, because moving the scene on is what SPENDS a standing
|
|
28
|
+
* roll: once the narrator has changed the location or logged the scene, the
|
|
29
|
+
* player's window to burn momentum on the last roll has closed.
|
|
30
|
+
*/
|
|
31
|
+
export default storyFlow.tool({
|
|
23
32
|
description:
|
|
24
33
|
"Lightweight state sync for during gameplay. Handles location changes, NPC additions, clock additions, time changes, and session log entries. Resource changes (health/spirit/supply/momentum) are auto-applied by action_roll — only use those fields here for manual adjustments like resting or trading. Pass only what changed.",
|
|
34
|
+
when: "playing",
|
|
25
35
|
inputSchema: z.object({
|
|
26
36
|
location: z.string().max(200).describe("New location name").optional(),
|
|
27
37
|
locationDesc: z.string().max(500).describe("Short location description").optional(),
|
|
@@ -54,117 +64,120 @@ export default gameSlot.updateTool({
|
|
|
54
64
|
storyComplete: z.boolean().describe("Mark story as complete").optional(),
|
|
55
65
|
logEntry: z.string().max(500).describe("Short log entry for this scene").optional(),
|
|
56
66
|
}),
|
|
57
|
-
execute(args,
|
|
58
|
-
|
|
59
|
-
|
|
67
|
+
execute: (args, ctx) =>
|
|
68
|
+
gameSlot.update(ctx, (state) => {
|
|
69
|
+
const warnings: string[] = [];
|
|
70
|
+
const clockEvents: { clock: string; trigger: string }[] = [];
|
|
60
71
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
72
|
+
// Resources
|
|
73
|
+
if (args.health !== undefined)
|
|
74
|
+
state.health = Math.max(0, Math.min(MAX_RESOURCE, args.health));
|
|
75
|
+
if (args.spirit !== undefined)
|
|
76
|
+
state.spirit = Math.max(0, Math.min(MAX_RESOURCE, args.spirit));
|
|
77
|
+
if (args.supply !== undefined)
|
|
78
|
+
state.supply = Math.max(0, Math.min(MAX_RESOURCE, args.supply));
|
|
79
|
+
if (args.momentum !== undefined)
|
|
80
|
+
state.momentum = Math.max(MIN_MOMENTUM, Math.min(state.maxMomentum, args.momentum));
|
|
67
81
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
82
|
+
// Location
|
|
83
|
+
if (args.location !== undefined) state.currentLocation = args.location;
|
|
84
|
+
if (args.locationDesc !== undefined) state.currentSceneContext = args.locationDesc;
|
|
85
|
+
if (args.timeOfDay !== undefined) state.timeOfDay = args.timeOfDay;
|
|
72
86
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
// Add NPC
|
|
88
|
+
if (args.addNpcName) {
|
|
89
|
+
if (state.npcs.length >= MAX_NPCS) {
|
|
90
|
+
warnings.push(
|
|
91
|
+
`NPC limit of ${MAX_NPCS} reached — mark an existing NPC deceased or background instead.`,
|
|
92
|
+
);
|
|
93
|
+
} else {
|
|
94
|
+
state.npcs.push(
|
|
95
|
+
makeNpc({
|
|
96
|
+
id: nextSeqId(state.npcs, "npc"),
|
|
97
|
+
name: args.addNpcName,
|
|
98
|
+
description: args.addNpcDesc,
|
|
99
|
+
disposition: args.addNpcDisposition,
|
|
100
|
+
agenda: args.addNpcAgenda,
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
89
104
|
}
|
|
90
|
-
}
|
|
91
105
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
106
|
+
// Update NPC
|
|
107
|
+
if (args.updateNpcId) {
|
|
108
|
+
const npc = state.npcs.find((n) => n.id === args.updateNpcId);
|
|
109
|
+
if (npc) {
|
|
110
|
+
if (args.updateNpcDisposition !== undefined) npc.disposition = args.updateNpcDisposition;
|
|
111
|
+
if (args.updateNpcBond !== undefined) npc.bond = args.updateNpcBond;
|
|
112
|
+
if (args.updateNpcStatus !== undefined) npc.status = args.updateNpcStatus;
|
|
113
|
+
} else {
|
|
114
|
+
warnings.push(`No NPC with id ${args.updateNpcId}.`);
|
|
115
|
+
}
|
|
101
116
|
}
|
|
102
|
-
}
|
|
103
117
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
118
|
+
// Add clock
|
|
119
|
+
if (args.addClockName) {
|
|
120
|
+
if (state.clocks.length >= MAX_CLOCKS) {
|
|
121
|
+
warnings.push(`Clock limit of ${MAX_CLOCKS} reached — remove a finished clock first.`);
|
|
122
|
+
} else {
|
|
123
|
+
state.clocks.push({
|
|
124
|
+
id: nextSeqId(state.clocks, "clock"),
|
|
125
|
+
name: args.addClockName,
|
|
126
|
+
clockType: args.addClockType ?? "threat",
|
|
127
|
+
segments: args.addClockSegments ?? DEFAULT_CLOCK_SEGMENTS,
|
|
128
|
+
filled: 0,
|
|
129
|
+
triggerDescription: args.addClockTrigger ?? "",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
117
132
|
}
|
|
118
|
-
}
|
|
119
133
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
134
|
+
// Advance clock
|
|
135
|
+
if (args.advanceClockName) {
|
|
136
|
+
const clock = state.clocks.find((c) => c.name === args.advanceClockName);
|
|
137
|
+
if (clock && clock.filled < clock.segments) {
|
|
138
|
+
clock.filled = Math.min(clock.segments, clock.filled + 1);
|
|
139
|
+
if (clock.filled >= clock.segments) {
|
|
140
|
+
clockEvents.push({ clock: clock.name, trigger: clock.triggerDescription });
|
|
141
|
+
}
|
|
127
142
|
}
|
|
128
143
|
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Remove clock
|
|
132
|
-
if (args.removeClockName) {
|
|
133
|
-
state.clocks = state.clocks.filter((c) => c.name !== args.removeClockName);
|
|
134
|
-
}
|
|
135
144
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
state.storyBlueprint.currentAct + 1,
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
if (args.storyComplete && state.storyBlueprint) {
|
|
144
|
-
state.storyBlueprint.storyComplete = true;
|
|
145
|
-
}
|
|
145
|
+
// Remove clock
|
|
146
|
+
if (args.removeClockName) {
|
|
147
|
+
state.clocks = state.clocks.filter((c) => c.name !== args.removeClockName);
|
|
148
|
+
}
|
|
146
149
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
);
|
|
158
|
-
}
|
|
150
|
+
// Story arc
|
|
151
|
+
if (args.advanceAct && state.storyBlueprint) {
|
|
152
|
+
state.storyBlueprint.currentAct = Math.min(
|
|
153
|
+
state.storyBlueprint.acts.length,
|
|
154
|
+
state.storyBlueprint.currentAct + 1,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
if (args.storyComplete && state.storyBlueprint) {
|
|
158
|
+
state.storyBlueprint.storyComplete = true;
|
|
159
|
+
}
|
|
159
160
|
|
|
160
|
-
|
|
161
|
-
|
|
161
|
+
// Session log
|
|
162
|
+
if (args.logEntry) {
|
|
163
|
+
pushCapped(
|
|
164
|
+
state.sessionLog,
|
|
165
|
+
{
|
|
166
|
+
scene: state.sceneCount,
|
|
167
|
+
summary: args.logEntry,
|
|
168
|
+
location: state.currentLocation,
|
|
169
|
+
},
|
|
170
|
+
MAX_SESSION_LOG,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
162
173
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
174
|
+
return {
|
|
175
|
+
success: true,
|
|
176
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
177
|
+
clockEvents,
|
|
178
|
+
...stateSummary(state),
|
|
179
|
+
};
|
|
180
|
+
}),
|
|
181
|
+
sendFrom: (result) =>
|
|
182
|
+
result.gameOver ? { type: "DOWNED" as const } : { type: "SETTLED" as const },
|
|
170
183
|
});
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
/**
|
|
3
|
+
* Specs for the spoken-summary app's declaration and its four legs.
|
|
4
|
+
*
|
|
5
|
+
* **The body itself is not driven here**, and that is a property of what a
|
|
6
|
+
* workflow template demonstrates rather than a gap: imported through vitest
|
|
7
|
+
* with no bundler in the path, a `"use step"` function is an ordinary async
|
|
8
|
+
* function — so its HTTP handling, its fatal/retryable classification and what
|
|
9
|
+
* it returns are all testable, while durability, suspension and replay are not.
|
|
10
|
+
* A body test that looked like a durability test would be the worse failure;
|
|
11
|
+
* the real thing is exercised end to end by `aai-cli`'s
|
|
12
|
+
* `dev-workflow.scenario.test.ts`.
|
|
13
|
+
*
|
|
14
|
+
* The two legs worth their own sections are the ones the SDK grew for this
|
|
15
|
+
* template. `speak` is where a step SPEAKS and STORES, and the assertion that
|
|
16
|
+
* matters is that it returns an id rather than bytes — a step is journaled by
|
|
17
|
+
* its return value, and audio in one is megabytes replayed on every resume.
|
|
18
|
+
*/
|
|
19
|
+
|
|
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";
|
|
29
|
+
import { FatalError, RetryableError } from "workflow";
|
|
30
|
+
import agentDef, { spokenSummary } from "./agent.ts";
|
|
31
|
+
import { speak, spokenSummaryFlow, summarize } from "./workflows/summarize.ts";
|
|
32
|
+
import { createJob, pollTranscript, uploadToProvider } from "./workflows/transcribe.ts";
|
|
33
|
+
|
|
34
|
+
/** The id every spec below uploads under. */
|
|
35
|
+
const UPLOAD_ID = "upl_test";
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
// WRITABLE, because this app's whole second half stores a file — and it is
|
|
39
|
+
// opt-in precisely so a step that wrote one nobody meant it to would fail.
|
|
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 },
|
|
45
|
+
);
|
|
46
|
+
// The step env, which is where `requireStepEnv` and `stepSpeak` read the key.
|
|
47
|
+
// `vi.stubEnv` rather than an assignment: `unstubEnvs` undoes it before every
|
|
48
|
+
// test, so nothing here has to remember to put it back.
|
|
49
|
+
vi.stubEnv("ASSEMBLYAI_API_KEY", "test-key");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("the declaration", () => {
|
|
53
|
+
test("is a workflow app with the one workflow the page starts by name", () => {
|
|
54
|
+
// The page calls `api.start("spokenSummary", …)`, so a rename here is a
|
|
55
|
+
// runtime 400 rather than a compile error. This is what pins it.
|
|
56
|
+
expect(Object.keys(agentDef.workflows ?? {})).toEqual(["spokenSummary"]);
|
|
57
|
+
expect(agentDef.page).toBe("static");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("declares no providers and exactly the one credential its steps read", () => {
|
|
61
|
+
// A workflow app has no session, so nothing else in its config could name
|
|
62
|
+
// one — and one AssemblyAI key covers transcription, the model and the voice.
|
|
63
|
+
expect(agentDef.requiredEnv).toEqual(["ASSEMBLYAI_API_KEY"]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("takes the recording as an UPLOAD, which is what makes the form a file picker", () => {
|
|
67
|
+
expect(spokenSummary.uploads).toEqual(["recording"]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("offers real voice ids, so the synthesis cannot fail silently in band", async () => {
|
|
71
|
+
const parsed = spokenSummary.input?.["~standard"].validate({
|
|
72
|
+
recording: UPLOAD_ID,
|
|
73
|
+
voice: "not-a-voice",
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect((await parsed)?.issues).toBeTruthy();
|
|
77
|
+
expect(
|
|
78
|
+
(await spokenSummary.input?.["~standard"].validate({ recording: UPLOAD_ID, voice: "jane" }))
|
|
79
|
+
?.issues,
|
|
80
|
+
).toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("transcribing", () => {
|
|
85
|
+
test("streams the stored recording to the provider and keeps the URL it answered", async () => {
|
|
86
|
+
const provider = installStubTranscribe({ audioUrl: "https://cdn/aai/1" });
|
|
87
|
+
installStubReporter();
|
|
88
|
+
|
|
89
|
+
await expect(uploadToProvider(UPLOAD_ID)).resolves.toEqual({
|
|
90
|
+
audioUrl: "https://cdn/aai/1",
|
|
91
|
+
});
|
|
92
|
+
expect(provider.calls[0]?.leg).toBe("upload");
|
|
93
|
+
// The bytes really went, and they went as the file rather than as JSON.
|
|
94
|
+
expect(provider.calls[0]?.body?.length).toBe(64);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("a 429 from the provider is RETRYABLE and a 400 is not", async () => {
|
|
98
|
+
const first = installStubTranscribe({ failure: { status: 429, message: "slow down" } });
|
|
99
|
+
installStubReporter();
|
|
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
|
+
first.restore();
|
|
104
|
+
|
|
105
|
+
installStubTranscribe({ failure: { status: 400, message: "bad model" } });
|
|
106
|
+
await expect(createJob("https://cdn/aai/1")).rejects.toBeInstanceOf(FatalError);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("a job the provider gave up on is FATAL — no number of polls changes it", async () => {
|
|
110
|
+
installStubTranscribe({ jobError: "corrupt audio" });
|
|
111
|
+
|
|
112
|
+
await expect(pollTranscript(UPLOAD_ID, "t_1")).rejects.toThrow("corrupt audio");
|
|
113
|
+
await expect(pollTranscript(UPLOAD_ID, "t_1")).rejects.toBeInstanceOf(FatalError);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("`done` is decided here, so the body never reads a provider's vocabulary", async () => {
|
|
117
|
+
installStubTranscribe({ pendingPolls: 1 });
|
|
118
|
+
|
|
119
|
+
await expect(pollTranscript(UPLOAD_ID, "t_1")).resolves.toEqual({ done: false });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("a finished poll carries the transcript, named by the FILENAME", async () => {
|
|
123
|
+
// ONE request, not two: this used to poll for a status and then fetch the
|
|
124
|
+
// identical URL again for the text the poll already had in its hand.
|
|
125
|
+
const provider = installStubTranscribe({ text: " we shipped it ", durationSec: 12.4 });
|
|
126
|
+
installStubReporter();
|
|
127
|
+
|
|
128
|
+
await expect(pollTranscript(UPLOAD_ID, "t_1")).resolves.toEqual({
|
|
129
|
+
done: true,
|
|
130
|
+
transcript: { source: "standup.wav", durationMs: 12_400, text: "we shipped it" },
|
|
131
|
+
});
|
|
132
|
+
expect(provider.calls).toHaveLength(1);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("a recording of silence is FATAL rather than an empty summary", async () => {
|
|
136
|
+
// The failure this template is most likely to meet: silence transcribes
|
|
137
|
+
// successfully to nothing, and everything downstream would then be asked to
|
|
138
|
+
// summarize and speak no words at all.
|
|
139
|
+
installStubTranscribe({ text: " ", durationSec: 3 });
|
|
140
|
+
|
|
141
|
+
await expect(pollTranscript(UPLOAD_ID, "t_1")).rejects.toThrow("no speech in that recording");
|
|
142
|
+
await expect(pollTranscript(UPLOAD_ID, "t_1")).rejects.toBeInstanceOf(FatalError);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("summarizing", () => {
|
|
147
|
+
test("asks the model for a spoken script as well as points, and keeps both", async () => {
|
|
148
|
+
const calls = installStubGateway(
|
|
149
|
+
JSON.stringify({
|
|
150
|
+
headline: "Launch is on",
|
|
151
|
+
points: ["Ship Tuesday", "Two bugs left"],
|
|
152
|
+
spoken: "The launch is on for Tuesday, with two bugs still open.",
|
|
153
|
+
}),
|
|
154
|
+
);
|
|
155
|
+
installStubReporter();
|
|
156
|
+
|
|
157
|
+
const summary = await summarize("we ship tuesday");
|
|
158
|
+
|
|
159
|
+
expect(summary.headline).toBe("Launch is on");
|
|
160
|
+
expect(summary.points).toEqual(["Ship Tuesday", "Two bugs left"]);
|
|
161
|
+
expect(summary.spoken).toBe("The launch is on for Tuesday, with two bugs still open.");
|
|
162
|
+
// The prompt really carries the transcript, and really asks for the two
|
|
163
|
+
// shapes — a page that got bullets read aloud is the failure this prevents.
|
|
164
|
+
expect(calls[0]?.prompt).toContain("we ship tuesday");
|
|
165
|
+
expect(calls[0]?.prompt).toContain("READ ALOUD");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("caps the points at what the schema promises the page", async () => {
|
|
169
|
+
installStubGateway(
|
|
170
|
+
JSON.stringify({
|
|
171
|
+
headline: "Many things",
|
|
172
|
+
points: ["a", "b", "c", "d", "e", "f"],
|
|
173
|
+
spoken: "Several things happened.",
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
installStubReporter();
|
|
177
|
+
|
|
178
|
+
expect((await summarize("…")).points).toHaveLength(4);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("a reply with no spoken script FAILS rather than defaulting to silence", async () => {
|
|
182
|
+
installStubGateway(JSON.stringify({ headline: "Launch is on", points: ["Ship Tuesday"] }));
|
|
183
|
+
installStubReporter();
|
|
184
|
+
|
|
185
|
+
await expect(summarize("…")).rejects.toThrow(/did not match the shape/);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("speaking", () => {
|
|
190
|
+
test("stores a WAV and returns its ID — never the bytes", async () => {
|
|
191
|
+
const speech = installStubSpeech({ pcmBytes: 48_000 });
|
|
192
|
+
installStubReporter();
|
|
193
|
+
|
|
194
|
+
const spoken = await speak("The launch is on for Tuesday.");
|
|
195
|
+
|
|
196
|
+
// An id, because a step is journaled by its return value: audio in one is
|
|
197
|
+
// megabytes replayed on every resume.
|
|
198
|
+
expect(spoken).toEqual({ audio: "upl_stub_1", durationMs: 1000 });
|
|
199
|
+
expect(speech.calls[0]?.text).toBe("The launch is on for Tuesday.");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("what it stored is a real WAV, named and typed for the browser", async () => {
|
|
203
|
+
installStubSpeech({ pcmBytes: 4000 });
|
|
204
|
+
installStubReporter();
|
|
205
|
+
|
|
206
|
+
const { audio } = await speak("Hello.");
|
|
207
|
+
|
|
208
|
+
await expect(uploadInfo(audio)).resolves.toMatchObject({
|
|
209
|
+
name: "summary.wav",
|
|
210
|
+
// The byte route serves this as `Content-Type`, and a browser will not
|
|
211
|
+
// play inline a file it was handed as octet-stream.
|
|
212
|
+
type: "audio/wav",
|
|
213
|
+
size: 44 + 4000,
|
|
214
|
+
});
|
|
215
|
+
const { bytes } = await readUpload(audio, { end: 12 });
|
|
216
|
+
expect(String.fromCharCode(...bytes.subarray(0, 4))).toBe("RIFF");
|
|
217
|
+
expect(String.fromCharCode(...bytes.subarray(8, 12))).toBe("WAVE");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("passes a chosen voice through, and omits it entirely when none was chosen", async () => {
|
|
221
|
+
const speech = installStubSpeech();
|
|
222
|
+
installStubReporter();
|
|
223
|
+
|
|
224
|
+
await speak("Hello.", "michael");
|
|
225
|
+
await speak("Hello.");
|
|
226
|
+
|
|
227
|
+
expect(speech.calls[0]?.voice).toBe("michael");
|
|
228
|
+
// The SDK's own default, not one this template restates.
|
|
229
|
+
expect(speech.calls[1]?.voice).toBe("jane");
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe("the whole run", () => {
|
|
234
|
+
/**
|
|
235
|
+
* Answer every leg's HTTP, so the BODY can be driven end to end.
|
|
236
|
+
*
|
|
237
|
+
* Imported through vitest with no bundler in the path, a `"use workflow"`
|
|
238
|
+
* function is an ordinary async function — so what this exercises is the
|
|
239
|
+
* ORDER the legs are wired in and the shape they hand each other, which is
|
|
240
|
+
* the one thing the per-leg specs above cannot see. Durability, suspension
|
|
241
|
+
* and replay are not testable here and are not what this claims.
|
|
242
|
+
*
|
|
243
|
+
* The job answers `completed` on its FIRST poll, deliberately: a second poll
|
|
244
|
+
* would reach the durable `sleep`, which outside a real run is not a wait
|
|
245
|
+
* this spec should be taking.
|
|
246
|
+
*
|
|
247
|
+
* **The model call goes through this too, not `installStubGateway`.** A
|
|
248
|
+
* published `stepFetch` is what `stepGenerate` makes its request with, so a
|
|
249
|
+
* global-fetch stub is never reached once one exists — which is exactly the
|
|
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.
|
|
257
|
+
*/
|
|
258
|
+
function stubProvider(reply: { headline: string; points: string[]; spoken: string }) {
|
|
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,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
test("transcribes, summarizes, speaks, and reports the file it made", async () => {
|
|
272
|
+
stubProvider({
|
|
273
|
+
headline: "Launch is on",
|
|
274
|
+
points: ["Ship Tuesday"],
|
|
275
|
+
spoken: "The launch is on for Tuesday.",
|
|
276
|
+
});
|
|
277
|
+
installStubReporter();
|
|
278
|
+
installStubSpeech();
|
|
279
|
+
|
|
280
|
+
const summary = await spokenSummaryFlow({ recording: UPLOAD_ID });
|
|
281
|
+
|
|
282
|
+
expect(summary).toEqual({
|
|
283
|
+
source: "standup.wav",
|
|
284
|
+
durationMs: 42_000,
|
|
285
|
+
words: 8,
|
|
286
|
+
headline: "Launch is on",
|
|
287
|
+
points: ["Ship Tuesday"],
|
|
288
|
+
spoken: "The launch is on for Tuesday.",
|
|
289
|
+
transcript: "we ship tuesday and two bugs are left",
|
|
290
|
+
// The output carries an ID, never the audio — the rule the whole
|
|
291
|
+
// template exists to demonstrate.
|
|
292
|
+
audio: "upl_stub_1",
|
|
293
|
+
audioDurationMs: 250,
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("the voice the form chose reaches the synthesizer", async () => {
|
|
298
|
+
stubProvider({ headline: "Launch is on", points: ["Ship Tuesday"], spoken: "Spoken." });
|
|
299
|
+
installStubReporter();
|
|
300
|
+
const speech = installStubSpeech();
|
|
301
|
+
|
|
302
|
+
await spokenSummaryFlow({ recording: UPLOAD_ID, voice: "michael" });
|
|
303
|
+
|
|
304
|
+
expect(speech.calls[0]).toMatchObject({ text: "Spoken.", voice: "michael" });
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("a recording the provider gave up on fails the run rather than half-summarizing", async () => {
|
|
308
|
+
installStubTranscribe({ jobError: "corrupt audio" });
|
|
309
|
+
installStubReporter();
|
|
310
|
+
installStubSpeech();
|
|
311
|
+
|
|
312
|
+
await expect(spokenSummaryFlow({ recording: UPLOAD_ID })).rejects.toThrow("corrupt audio");
|
|
313
|
+
});
|
|
314
|
+
});
|