@alexkroman1/aai-cli 5.14.0 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{_agent-C8Jk-ZcP.mjs → _agent-vGW5SBnX.mjs} +23 -5
- package/dist/_agent.d.ts +18 -0
- package/dist/{_api-client-BX8Qv51n.mjs → _api-client-LgLksMhN.mjs} +45 -8
- package/dist/_api-client.d.ts +39 -0
- package/dist/{_bundler-BuIyDdvs.mjs → _bundler-BapCUUJN.mjs} +42 -14
- package/dist/_bundler.d.ts +43 -5
- package/dist/_cli-common.d.ts +67 -3
- package/dist/{_config-CzwAWOWr.mjs → _config-D_s09e7g.mjs} +36 -18
- package/dist/_deploy.d.ts +2 -5
- package/dist/_dev-restart.d.ts +6 -1
- package/dist/{_dev-server-Ch7K8e8i.mjs → _dev-server-DQ1t9sbw.mjs} +94 -38
- package/dist/_dev-server.d.ts +30 -2
- package/dist/_e2e-test-utils.d.ts +80 -0
- package/dist/_fault-mode.d.ts +175 -0
- package/dist/{_init-CbkrE2wq.mjs → _init-CpISqoR8.mjs} +3 -3
- package/dist/{_output-CC300DzW.mjs → _output-CKkmWs7i.mjs} +8 -1
- package/dist/_output.d.ts +9 -2
- package/dist/{_server-common-61fL__39.mjs → _server-common-6e0QI4mq.mjs} +1 -1
- package/dist/{_slug-api-D30zPmsn.mjs → _slug-api-D2heILuC.mjs} +2 -2
- package/dist/{_studio-D2ciIhbi.mjs → _studio-zAJycZ8j.mjs} +3 -3
- package/dist/{_templates-B1kt3BGt.mjs → _templates-iDGjGiPf.mjs} +4 -6
- package/dist/{_typecheck-gate-4v9UBZHh.mjs → _typecheck-gate-BXvUNnfs.mjs} +1 -1
- package/dist/{_utils-0DHbcqUb.mjs → _utils-B8QmtFhK.mjs} +21 -7
- package/dist/_utils.d.ts +18 -2
- package/dist/{_vite-env-Dg_QlVv0.mjs → _vite-env-BNveawd1.mjs} +31 -1
- package/dist/_vite-env.d.ts +30 -0
- package/dist/build-BO1Ni6oJ.mjs +62 -0
- package/dist/build.d.ts +19 -0
- package/dist/cli-workflow.d.ts +10 -0
- package/dist/cli.d.ts +1 -2
- package/dist/cli.mjs +406 -224
- package/dist/{client-bundler-C9qXL8A2.mjs → client-bundler-B8Q9F8gJ.mjs} +3 -22
- package/dist/client-bundler.mjs +1 -1
- package/dist/{delete-DCRX3d96.mjs → delete-4I0uNR4a.mjs} +6 -7
- package/dist/delete.d.ts +2 -8
- package/dist/{deploy-DjsEV-40.mjs → deploy-DJ3HRcia.mjs} +13 -14
- package/dist/{dev-CvPH5dbF.mjs → dev-C6SWaAZP.mjs} +3 -3
- package/dist/{eject-LgBRfnbH.mjs → eject-Zrzk5KV3.mjs} +41 -15
- package/dist/eject.d.ts +30 -3
- package/dist/{init-BppxMR8r.mjs → init-BTRGiPQr.mjs} +61 -26
- package/dist/init.d.ts +11 -0
- package/dist/{login-BQQaxJhL.mjs → login-k0Z3VQu9.mjs} +5 -5
- package/dist/project-config.mjs +1 -1
- package/dist/scaffold/CLAUDE.md +498 -91
- package/dist/scaffold/package.json +11 -8
- package/dist/scaffold/pnpm-workspace.yaml +8 -0
- package/dist/scaffold/server.mjs +53 -57
- package/dist/{secret-Ds9dvqAj.mjs → secret-DzSQx1Ds.mjs} +6 -3
- package/dist/{storage-DbrQSHFk.mjs → storage-C-Do0mIA.mjs} +18 -6
- package/dist/{studio-GSFd4s5O.mjs → studio-BGVmCA2G.mjs} +10 -10
- package/dist/templates/code-interpreter/agent.ts +0 -2
- package/dist/templates/dispatch-center/agent.test.ts +17 -19
- package/dist/templates/dispatch-center/agent.ts +2 -29
- package/dist/templates/dispatch-center/client.tsx +33 -16
- package/dist/templates/dispatch-center/shared.ts +53 -19
- package/dist/templates/dispatch-center/tools/incident_add_note.ts +12 -14
- package/dist/templates/dispatch-center/tools/incident_create.ts +41 -44
- package/dist/templates/dispatch-center/tools/incident_escalate.ts +59 -61
- package/dist/templates/dispatch-center/tools/incident_get.ts +3 -4
- package/dist/templates/dispatch-center/tools/incident_triage.ts +43 -45
- package/dist/templates/dispatch-center/tools/incident_update_status.ts +39 -41
- package/dist/templates/dispatch-center/tools/ops_dashboard.ts +2 -5
- package/dist/templates/dispatch-center/tools/ops_protocols.ts +1 -1
- package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +20 -23
- package/dist/templates/dispatch-center/tools/resources_dispatch.ts +52 -54
- package/dist/templates/dispatch-center/tools/resources_get_available.ts +2 -4
- package/dist/templates/dispatch-center/tools/resources_update_status.ts +39 -42
- package/dist/templates/embedded-assets/agent.test.ts +83 -0
- package/dist/templates/embedded-assets/agent.ts +1 -48
- package/dist/templates/embedded-assets/shared.ts +17 -0
- package/dist/templates/embedded-assets/system-prompt.md +13 -0
- package/dist/templates/embedded-assets/tools/list_topics.ts +9 -0
- package/dist/templates/embedded-assets/tools/search_knowledge.ts +26 -0
- package/dist/templates/health-assistant/agent.test.ts +169 -0
- package/dist/templates/health-assistant/agent.ts +1 -166
- package/dist/templates/health-assistant/fda.ts +100 -0
- package/dist/templates/health-assistant/tools/check_drug_interaction.ts +59 -0
- package/dist/templates/health-assistant/tools/medication_lookup.ts +31 -0
- package/dist/templates/infocom-adventure/agent.test.ts +205 -0
- package/dist/templates/infocom-adventure/agent.ts +3 -106
- package/dist/templates/infocom-adventure/client.tsx +28 -12
- package/dist/templates/infocom-adventure/shared.ts +28 -11
- package/dist/templates/infocom-adventure/tools/game_state_drop.ts +13 -0
- package/dist/templates/infocom-adventure/tools/game_state_flag.ts +13 -0
- package/dist/templates/infocom-adventure/tools/game_state_get.ts +16 -0
- package/dist/templates/infocom-adventure/tools/game_state_history.ts +14 -0
- package/dist/templates/infocom-adventure/tools/game_state_move.ts +14 -0
- package/dist/templates/infocom-adventure/tools/game_state_restart.ts +13 -0
- package/dist/templates/infocom-adventure/tools/game_state_score.ts +13 -0
- package/dist/templates/infocom-adventure/tools/game_state_take.ts +13 -0
- package/dist/templates/link-digest/agent.test.ts +223 -0
- package/dist/templates/link-digest/agent.ts +79 -0
- package/dist/templates/link-digest/client.tsx +146 -0
- package/dist/templates/link-digest/workflows/digest.ts +248 -0
- package/dist/templates/math-buddy/agent.ts +0 -2
- package/dist/templates/night-owl/agent.test.ts +80 -0
- package/dist/templates/night-owl/agent.ts +1 -68
- package/dist/templates/night-owl/system-prompt.md +10 -0
- package/dist/templates/night-owl/tools/recommend.ts +64 -0
- package/dist/templates/personal-finance/agent.ts +0 -2
- package/dist/templates/pizza-ordering/agent.test.ts +52 -47
- package/dist/templates/pizza-ordering/agent.ts +9 -158
- package/dist/templates/pizza-ordering/shared.ts +35 -12
- package/dist/templates/pizza-ordering/tools/add_pizza.ts +31 -0
- package/dist/templates/pizza-ordering/tools/place_order.ts +22 -0
- package/dist/templates/pizza-ordering/tools/remove_pizza.ts +22 -0
- package/dist/templates/pizza-ordering/tools/set_customer_name.ts +13 -0
- package/dist/templates/pizza-ordering/tools/update_pizza.ts +28 -0
- package/dist/templates/pizza-ordering/tools/view_order.ts +21 -0
- package/dist/templates/plan-and-execute/agent.test.ts +411 -0
- package/dist/templates/plan-and-execute/agent.ts +23 -0
- package/dist/templates/plan-and-execute/client.tsx +115 -0
- package/dist/templates/plan-and-execute/graph.ts +178 -0
- package/dist/templates/plan-and-execute/prompts.ts +110 -0
- package/dist/templates/plan-and-execute/shared.ts +161 -0
- package/dist/templates/plan-and-execute/system-prompt.md +18 -0
- package/dist/templates/plan-and-execute/tools/plan_status.ts +20 -0
- package/dist/templates/plan-and-execute/tools/revise_plan.ts +63 -0
- package/dist/templates/plan-and-execute/tools/start_plan.ts +51 -0
- package/dist/templates/plan-and-execute/tools/work_next_step.ts +101 -0
- package/dist/templates/recap-workflow/agent.test.ts +762 -0
- package/dist/templates/recap-workflow/agent.ts +95 -0
- package/dist/templates/recap-workflow/shared.ts +43 -0
- package/dist/templates/recap-workflow/tools/cancel_recap.ts +23 -0
- package/dist/templates/recap-workflow/tools/keep_transcript.ts +31 -0
- package/dist/templates/recap-workflow/tools/recap_progress.ts +27 -0
- package/dist/templates/recap-workflow/tools/recap_status.ts +46 -0
- package/dist/templates/recap-workflow/tools/request_recap.ts +43 -0
- package/dist/templates/recap-workflow/workflows/recap.ts +544 -0
- package/dist/templates/recap-workflow/workflows/tokens.ts +26 -0
- package/dist/templates/redline/agent.test.ts +222 -0
- package/dist/templates/redline/agent.ts +89 -0
- package/dist/templates/redline/client.tsx +184 -0
- package/dist/templates/redline/workflows/prompts.ts +68 -0
- package/dist/templates/redline/workflows/redline.ts +258 -0
- package/dist/templates/research-workflow/agent.test.ts +525 -0
- package/dist/templates/research-workflow/agent.ts +94 -0
- package/dist/templates/research-workflow/shared.ts +35 -0
- package/dist/templates/research-workflow/tools/file_it_now.ts +22 -0
- package/dist/templates/research-workflow/tools/request_research.ts +33 -0
- package/dist/templates/research-workflow/tools/research_progress.ts +26 -0
- package/dist/templates/research-workflow/tools/research_status.ts +40 -0
- package/dist/templates/research-workflow/workflows/prompts.ts +143 -0
- package/dist/templates/research-workflow/workflows/research.ts +558 -0
- package/dist/templates/retail/agent.test.ts +236 -172
- package/dist/templates/retail/agent.ts +5 -42
- package/dist/templates/retail/client.tsx +33 -11
- package/dist/templates/retail/registry.test.ts +31 -16
- package/dist/templates/retail/resolve.ts +28 -64
- package/dist/templates/retail/shared.test.ts +4 -3
- package/dist/templates/retail/shared.ts +24 -7
- package/dist/templates/retail/store.test.ts +12 -10
- package/dist/templates/retail/store.ts +69 -30
- package/dist/templates/retail/tools/cancel_pending_order.ts +4 -9
- package/dist/templates/retail/tools/exchange_delivered_order_items.ts +22 -11
- package/dist/templates/retail/tools/find_user_id_by_email.ts +5 -9
- package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +5 -7
- package/dist/templates/retail/tools/get_item_details.ts +5 -7
- package/dist/templates/retail/tools/get_order_details.ts +5 -7
- package/dist/templates/retail/tools/get_product_details.ts +4 -7
- package/dist/templates/retail/tools/get_user_details.ts +4 -7
- package/dist/templates/retail/tools/list_all_product_types.ts +5 -7
- package/dist/templates/retail/tools/modify_pending_order_address.ts +4 -9
- package/dist/templates/retail/tools/modify_pending_order_items.ts +4 -16
- package/dist/templates/retail/tools/modify_pending_order_payment.ts +3 -9
- package/dist/templates/retail/tools/modify_user_address.ts +4 -9
- package/dist/templates/retail/tools/return_delivered_order_items.ts +3 -9
- package/dist/templates/retail/tools/transfer_to_human_agents.ts +1 -1
- package/dist/templates/solo-rpg/agent.test.ts +152 -29
- package/dist/templates/solo-rpg/agent.ts +3 -21
- package/dist/templates/solo-rpg/client.tsx +19 -6
- package/dist/templates/solo-rpg/shared.ts +19 -11
- package/dist/templates/solo-rpg/tools/action_roll.ts +2 -4
- package/dist/templates/solo-rpg/tools/burn_momentum.ts +2 -4
- package/dist/templates/solo-rpg/tools/check_state.ts +6 -6
- package/dist/templates/solo-rpg/tools/load_game.ts +1 -1
- package/dist/templates/solo-rpg/tools/oracle.ts +18 -11
- package/dist/templates/solo-rpg/tools/save_game.ts +10 -7
- package/dist/templates/solo-rpg/tools/setup_character.ts +1 -1
- package/dist/templates/solo-rpg/tools/update_state.ts +3 -4
- package/dist/templates/support-line/agent.test.ts +343 -0
- package/dist/templates/support-line/agent.ts +27 -0
- package/dist/templates/support-line/client.tsx +122 -0
- package/dist/templates/support-line/graph.ts +224 -0
- package/dist/templates/support-line/knowledge.json +65 -0
- package/dist/templates/support-line/prompts.ts +95 -0
- package/dist/templates/support-line/shared.ts +240 -0
- package/dist/templates/support-line/system-prompt.md +17 -0
- package/dist/templates/support-line/tools/answer_question.ts +72 -0
- package/dist/templates/support-line/tools/list_topics.ts +22 -0
- package/dist/templates/support-line/tools/log_ticket.ts +30 -0
- package/dist/templates/transcription-workflow/agent.test.ts +563 -0
- package/dist/templates/transcription-workflow/agent.ts +92 -0
- package/dist/templates/transcription-workflow/client.tsx +234 -0
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +416 -0
- package/dist/templates/transcription-workflow/workflows/wav.ts +314 -0
- package/dist/templates/travel-concierge/agent.test.ts +338 -0
- package/dist/templates/travel-concierge/agent.ts +29 -0
- package/dist/templates/travel-concierge/client.tsx +161 -0
- package/dist/templates/travel-concierge/routing.ts +138 -0
- package/dist/templates/travel-concierge/shared.ts +547 -0
- package/dist/templates/travel-concierge/system-prompt.md +24 -0
- package/dist/templates/travel-concierge/tools/book_car_rental.ts +20 -0
- package/dist/templates/travel-concierge/tools/book_excursion.ts +18 -0
- package/dist/templates/travel-concierge/tools/book_hotel.ts +20 -0
- package/dist/templates/travel-concierge/tools/cancel_action.ts +7 -0
- package/dist/templates/travel-concierge/tools/cancel_ticket.ts +16 -0
- package/dist/templates/travel-concierge/tools/complete_or_escalate.ts +7 -0
- package/dist/templates/travel-concierge/tools/confirm_action.ts +7 -0
- package/dist/templates/travel-concierge/tools/lookup_booking.ts +35 -0
- package/dist/templates/travel-concierge/tools/search_car_rentals.ts +33 -0
- package/dist/templates/travel-concierge/tools/search_excursions.ts +37 -0
- package/dist/templates/travel-concierge/tools/search_flights.ts +43 -0
- package/dist/templates/travel-concierge/tools/search_hotels.ts +36 -0
- package/dist/templates/travel-concierge/tools/to_car_rental_assistant.ts +11 -0
- package/dist/templates/travel-concierge/tools/to_excursion_assistant.ts +11 -0
- package/dist/templates/travel-concierge/tools/to_flight_assistant.ts +11 -0
- package/dist/templates/travel-concierge/tools/to_hotel_assistant.ts +11 -0
- package/dist/templates/travel-concierge/tools/update_ticket.ts +22 -0
- package/dist/templates/web-researcher/agent.ts +0 -2
- package/dist/templates/web-researcher/system-prompt.md +10 -0
- package/dist/{test-2LWBcXvY.mjs → test-B6ZBxSpk.mjs} +2 -2
- package/dist/typecheck.mjs +17 -15
- package/dist/worker-bundler-CIdDhZ0c.mjs +398 -0
- package/dist/worker-bundler.d.ts +14 -0
- package/dist/worker-bundler.mjs +1 -108
- package/dist/workflow-B-O97AqI.mjs +125 -0
- package/dist/workflow-bundler.d.ts +133 -0
- package/dist/workflow.d.ts +67 -0
- package/package.json +13 -9
- package/dist/build-DQJ1ZSm9.mjs +0 -39
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
/// <reference types="vite/client" />
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Specs for the recap desk — the template that ports the Temporal workflow
|
|
6
|
+
* patterns onto a voice call.
|
|
7
|
+
*
|
|
8
|
+
* Three tiers, and the line between them is what this file is careful about:
|
|
9
|
+
*
|
|
10
|
+
* - **The tools**, against a stubbed `ctx.workflows`. That is the only honest
|
|
11
|
+
* way to unit-test them (the real client needs a Workflow DevKit world), and
|
|
12
|
+
* it is enough: what the agent half promises is that the handoff passes a
|
|
13
|
+
* correlation key, that a second request finds the live run instead of paying
|
|
14
|
+
* for a second transcription, and that a cancel says out loud what cancelling
|
|
15
|
+
* does not do.
|
|
16
|
+
* - **The steps**, directly. Imported through vitest with no bundler in the
|
|
17
|
+
* path a `"use step"` function is an ordinary async function, so its HTTP
|
|
18
|
+
* handling, its retryable/fatal split and its JSON contract with the model are
|
|
19
|
+
* all testable.
|
|
20
|
+
* - **The body's two helpers** — the poll loop and the compensation unwind —
|
|
21
|
+
* with `sleep` stubbed. What that asserts is ORDERING and BRANCHING, which is
|
|
22
|
+
* ordinary logic and worth pinning; it asserts nothing about durability,
|
|
23
|
+
* replay or suspension, and could not. `recapFlow` itself is deliberately not
|
|
24
|
+
* driven here for exactly that reason — a body test dressed up as a durability
|
|
25
|
+
* test would be the worse failure. `aai-cli`'s
|
|
26
|
+
* `dev-workflow.scenario.test.ts` is the tier that builds a project and
|
|
27
|
+
* runs a real one.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { ToolContext, WorkflowClient, WorkflowRunSnapshot } from "@alexkroman1/aai";
|
|
31
|
+
import {
|
|
32
|
+
createProgressStream,
|
|
33
|
+
createRunSnapshot,
|
|
34
|
+
createStubWorkflows,
|
|
35
|
+
createToolContext,
|
|
36
|
+
runTool,
|
|
37
|
+
withDiscoveredTools,
|
|
38
|
+
} from "@alexkroman1/aai/testing";
|
|
39
|
+
import { installStubGateway as stubGateway } from "@alexkroman1/aai/testing/vitest";
|
|
40
|
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
41
|
+
import { createHook, type Hook, sleep } from "workflow";
|
|
42
|
+
import authoredAgent from "./agent.ts";
|
|
43
|
+
import { recap } from "./shared.ts";
|
|
44
|
+
import {
|
|
45
|
+
askWhetherToKeep,
|
|
46
|
+
awaitTranscript,
|
|
47
|
+
checkTranscript,
|
|
48
|
+
compensate,
|
|
49
|
+
discardTranscript,
|
|
50
|
+
submitRecording,
|
|
51
|
+
summarize,
|
|
52
|
+
type TranscriptState,
|
|
53
|
+
} from "./workflows/recap.ts";
|
|
54
|
+
import { retentionToken } from "./workflows/tokens.ts";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The DevKit, with only its timer replaced.
|
|
58
|
+
*
|
|
59
|
+
* `sleep` throws outside a run, so the poll loop cannot be exercised at all
|
|
60
|
+
* without this — and everything else the module imports from `workflow`
|
|
61
|
+
* (`FatalError`, `RetryableError`) is a real class the steps' own specs assert
|
|
62
|
+
* against, so it is `importActual` plus one override rather than a fake module.
|
|
63
|
+
*/
|
|
64
|
+
vi.mock("workflow", async (importActual) => ({
|
|
65
|
+
...(await importActual<typeof import("workflow")>()),
|
|
66
|
+
sleep: vi.fn(async () => undefined),
|
|
67
|
+
createHook: vi.fn(),
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The def a DEPLOYED agent runs: authored, plus what `tools/` declares.
|
|
72
|
+
*
|
|
73
|
+
* The glob is written HERE rather than reached for from a shared helper because
|
|
74
|
+
* this file SHIPS: it is what a scaffolded project runs, so it may not import
|
|
75
|
+
* anything outside its own template, and `import.meta.glob` is expanded against
|
|
76
|
+
* the file containing it either way. This is the pattern a user writes.
|
|
77
|
+
*/
|
|
78
|
+
const agentDef = withDiscoveredTools(
|
|
79
|
+
authoredAgent,
|
|
80
|
+
import.meta.glob("./tools/*.ts", { eager: true }),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
/** Every tool here is driven through the agent's own table, by the name the model calls. */
|
|
84
|
+
const run = (name: string, args: Record<string, unknown>, ctx: ToolContext): Promise<unknown> =>
|
|
85
|
+
runTool(agentDef, name, args, ctx);
|
|
86
|
+
|
|
87
|
+
/** A `ctx.workflows` that records `start` and answers `find` from a fixture. */
|
|
88
|
+
function stubWorkflows(runs: WorkflowRunSnapshot[] = []): WorkflowClient {
|
|
89
|
+
return createStubWorkflows({
|
|
90
|
+
start: vi.fn(async () => "wrun_stub"),
|
|
91
|
+
get: vi.fn(async () => runs[0]),
|
|
92
|
+
find: vi.fn(async () => runs),
|
|
93
|
+
recent: vi.fn(async () => runs),
|
|
94
|
+
cancel: vi.fn(async () => true),
|
|
95
|
+
wakeUp: vi.fn(async () => 0),
|
|
96
|
+
// A tail of 0 means "one line written", which is the case the tools read.
|
|
97
|
+
streamTail: vi.fn(async () => 0),
|
|
98
|
+
stream: vi.fn(async () => createProgressStream([])),
|
|
99
|
+
listing: () => [{ name: "recap" }],
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A finished recap, as the workflow's output reaches the tools. */
|
|
104
|
+
function finishedOutput(over: { kept?: boolean; answered?: boolean } = {}) {
|
|
105
|
+
return {
|
|
106
|
+
url: "https://assembly.ai/wildfires.mp3",
|
|
107
|
+
headline: "Smoke reaches the east coast",
|
|
108
|
+
points: ["a", "b", "c"],
|
|
109
|
+
spoken: "Wildfire smoke drifted east and pushed air quality into the unhealthy range.",
|
|
110
|
+
minutes: 4,
|
|
111
|
+
kept: true,
|
|
112
|
+
answered: true,
|
|
113
|
+
requestedBy: "s_1",
|
|
114
|
+
...over,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
describe("the agent declares its workflow", () => {
|
|
119
|
+
test("under the name ctx.workflows.start resolves it by", () => {
|
|
120
|
+
expect(Object.keys(agentDef.workflows ?? {})).toEqual(["recap"]);
|
|
121
|
+
expect(agentDef.workflows?.recap).toBe(recap);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("with an input schema, so a bad URL fails at the call site", async () => {
|
|
125
|
+
const ok = await recap.input?.["~standard"].validate({
|
|
126
|
+
url: "https://example.com/a.mp3",
|
|
127
|
+
requestedBy: "s",
|
|
128
|
+
});
|
|
129
|
+
expect(ok?.issues).toBeUndefined();
|
|
130
|
+
const bad = await recap.input?.["~standard"].validate({ url: "not a url", requestedBy: "s" });
|
|
131
|
+
expect(bad?.issues).toBeDefined();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("and names the credential its steps read, so a deploy checks for it", () => {
|
|
135
|
+
// The steps reach the key with `requireStepEnv`, which no part of the agent
|
|
136
|
+
// config would otherwise mention.
|
|
137
|
+
expect(agentDef.requiredEnv).toContain("ASSEMBLYAI_API_KEY");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("as a VOICE agent, not a workflow app", () => {
|
|
141
|
+
// The distinction this template exists on: a caller is on the line, so the
|
|
142
|
+
// run is a handoff. `link-digest` is the other shape.
|
|
143
|
+
expect(agentDef.page).toBeUndefined();
|
|
144
|
+
// Discovered, not declared: every name here is a file in `tools/`.
|
|
145
|
+
expect(Object.keys(agentDef.tools).sort()).toEqual([
|
|
146
|
+
"cancel_recap",
|
|
147
|
+
"keep_transcript",
|
|
148
|
+
"recap_progress",
|
|
149
|
+
"recap_status",
|
|
150
|
+
"request_recap",
|
|
151
|
+
]);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("request_recap", () => {
|
|
156
|
+
test("starts a run keyed by the session, so a later turn can find it", async () => {
|
|
157
|
+
const workflows = stubWorkflows();
|
|
158
|
+
const ctx = createToolContext({ workflows });
|
|
159
|
+
const result = await run("request_recap", {}, ctx);
|
|
160
|
+
|
|
161
|
+
expect(workflows.start).toHaveBeenCalledWith(
|
|
162
|
+
recap,
|
|
163
|
+
{ url: expect.stringContaining("http"), requestedBy: ctx.sessionId },
|
|
164
|
+
// `key` is the DURABLE handle — a later call finds the run by it — and
|
|
165
|
+
// `notify` is the live one: this session is told when the run lands.
|
|
166
|
+
{ key: ctx.sessionId, notify: expect.stringContaining("one-sentence") },
|
|
167
|
+
);
|
|
168
|
+
expect(result).toMatchObject({ started: true, runId: "wrun_stub" });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("passes the definition rather than its name", async () => {
|
|
172
|
+
const workflows = stubWorkflows();
|
|
173
|
+
await run("request_recap", {}, createToolContext({ workflows }));
|
|
174
|
+
// The def overload is what types the input and turns a rename into a compile
|
|
175
|
+
// error; a string would still work at runtime and lose both.
|
|
176
|
+
expect(vi.mocked(workflows.start).mock.calls[0]?.[0]).toBe(recap);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("uses the caller's recording when they named one", async () => {
|
|
180
|
+
const workflows = stubWorkflows();
|
|
181
|
+
await run(
|
|
182
|
+
"request_recap",
|
|
183
|
+
{ url: "https://example.com/board.mp3" },
|
|
184
|
+
createToolContext({ workflows }),
|
|
185
|
+
);
|
|
186
|
+
expect(vi.mocked(workflows.start).mock.calls[0]?.[1]).toMatchObject({
|
|
187
|
+
url: "https://example.com/board.mp3",
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("REFUSES a second run while one is live, and hands back the one that exists", async () => {
|
|
192
|
+
// Temporal's workflow-id reuse policy, spelled with what this SDK has. The
|
|
193
|
+
// failure it prevents is not tidiness: a caller who asks twice would
|
|
194
|
+
// otherwise pay for the same recording being transcribed twice.
|
|
195
|
+
const workflows = stubWorkflows([createRunSnapshot({ workflow: "recap", status: "running" })]);
|
|
196
|
+
const result = await run("request_recap", {}, createToolContext({ workflows }));
|
|
197
|
+
expect(result).toMatchObject({ started: false, runId: "wrun_1" });
|
|
198
|
+
expect(workflows.start).not.toHaveBeenCalled();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("starts a fresh run once the previous one is terminal", async () => {
|
|
202
|
+
const workflows = stubWorkflows([
|
|
203
|
+
createRunSnapshot({ workflow: "recap", status: "completed", output: finishedOutput() }),
|
|
204
|
+
]);
|
|
205
|
+
const result = await run("request_recap", {}, createToolContext({ workflows }));
|
|
206
|
+
expect(result).toMatchObject({ started: true });
|
|
207
|
+
expect(workflows.start).toHaveBeenCalledTimes(1);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("recap_status", () => {
|
|
212
|
+
test("says nothing was started when the key has no runs", async () => {
|
|
213
|
+
const ctx = createToolContext({ workflows: stubWorkflows([]) });
|
|
214
|
+
const result = await run("recap_status", {}, ctx);
|
|
215
|
+
expect(result).toMatchObject({ runs: [], note: "Nothing started yet." });
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("reads back the ONE-SENTENCE version of a finished recap", async () => {
|
|
219
|
+
// The output carries a `spoken` field for exactly this: the headline and
|
|
220
|
+
// three points are for an eye, and this tool answers an ear.
|
|
221
|
+
const workflows = stubWorkflows([
|
|
222
|
+
createRunSnapshot({ workflow: "recap", status: "completed", output: finishedOutput() }),
|
|
223
|
+
]);
|
|
224
|
+
const result = (await run("recap_status", {}, createToolContext({ workflows }))) as {
|
|
225
|
+
runs: string[];
|
|
226
|
+
};
|
|
227
|
+
expect(result.runs[0]).toContain("air quality");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("names the transcript's fate, so an unanswered gate is not silent", async () => {
|
|
231
|
+
// The caller who never got round to answering should hear that the
|
|
232
|
+
// transcript is gone, not just the recap.
|
|
233
|
+
const runs = [
|
|
234
|
+
createRunSnapshot({
|
|
235
|
+
workflow: "recap",
|
|
236
|
+
status: "completed",
|
|
237
|
+
output: finishedOutput({ kept: false, answered: false }),
|
|
238
|
+
}),
|
|
239
|
+
];
|
|
240
|
+
const result = (await run(
|
|
241
|
+
"recap_status",
|
|
242
|
+
{},
|
|
243
|
+
createToolContext({ workflows: stubWorkflows(runs) }),
|
|
244
|
+
)) as { runs: string[] };
|
|
245
|
+
expect(result.runs[0]).toContain("transcript deleted");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("reports a live run as still working rather than as empty", async () => {
|
|
249
|
+
const ctx = createToolContext({
|
|
250
|
+
workflows: stubWorkflows([createRunSnapshot({ workflow: "recap", status: "running" })]),
|
|
251
|
+
});
|
|
252
|
+
const result = (await run("recap_status", {}, ctx)) as { runs: string[] };
|
|
253
|
+
expect(result.runs[0]).toContain("Still working");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("says a failed run was ROLLED BACK, because it was", async () => {
|
|
257
|
+
// The saga's whole point, said out loud: the run compensated before it
|
|
258
|
+
// failed, so there is nothing left on the account and nothing for the caller
|
|
259
|
+
// to chase.
|
|
260
|
+
const runs = [
|
|
261
|
+
createRunSnapshot({ workflow: "recap", status: "failed", error: "provider unavailable" }),
|
|
262
|
+
];
|
|
263
|
+
const result = (await run(
|
|
264
|
+
"recap_status",
|
|
265
|
+
{},
|
|
266
|
+
createToolContext({ workflows: stubWorkflows(runs) }),
|
|
267
|
+
)) as { runs: string[] };
|
|
268
|
+
expect(result.runs[0]).toContain("rolled back");
|
|
269
|
+
expect(result.runs[0]).toContain("provider unavailable");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("bounds how many past runs it reads aloud", async () => {
|
|
273
|
+
const workflows = stubWorkflows([]);
|
|
274
|
+
const ctx = createToolContext({ workflows });
|
|
275
|
+
await run("recap_status", {}, ctx);
|
|
276
|
+
// A voice reply cannot be a list of twenty runs.
|
|
277
|
+
expect(workflows.find).toHaveBeenCalledWith(recap, ctx.sessionId, { limit: 3 });
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe("recap_progress", () => {
|
|
282
|
+
test("reads the run's own progress line rather than its status", async () => {
|
|
283
|
+
const workflows = stubWorkflows([createRunSnapshot({ workflow: "recap", status: "running" })]);
|
|
284
|
+
vi.mocked(workflows.stream).mockResolvedValue(createProgressStream(["Transcript processing."]));
|
|
285
|
+
const result = await run("recap_progress", {}, createToolContext({ workflows }));
|
|
286
|
+
expect(result).toMatchObject({ progress: "Transcript processing." });
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("asks for the LAST line, not the whole log", async () => {
|
|
290
|
+
// Every poll narrates, so a twenty-minute run's whole log is eighty lines.
|
|
291
|
+
const workflows = stubWorkflows([createRunSnapshot({ workflow: "recap", status: "running" })]);
|
|
292
|
+
vi.mocked(workflows.stream).mockResolvedValue(createProgressStream(["a"]));
|
|
293
|
+
await run("recap_progress", {}, createToolContext({ workflows }));
|
|
294
|
+
expect(workflows.stream).toHaveBeenCalledWith("wrun_1", { startIndex: -1 });
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("a run that has written nothing yet says so WITHOUT opening the stream", async () => {
|
|
298
|
+
// Not a shortcut: an empty progress channel is never closed, so reading one
|
|
299
|
+
// waits for a line that arrives whenever the next step writes — i.e. the
|
|
300
|
+
// tool hangs instead of answering.
|
|
301
|
+
const workflows = stubWorkflows([createRunSnapshot({ workflow: "recap", status: "running" })]);
|
|
302
|
+
vi.mocked(workflows.streamTail).mockResolvedValue(-1);
|
|
303
|
+
const result = await run("recap_progress", {}, createToolContext({ workflows }));
|
|
304
|
+
expect(result).toMatchObject({ note: expect.stringContaining("nothing to report") });
|
|
305
|
+
expect(workflows.stream).not.toHaveBeenCalled();
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe("keep_transcript — the signal", () => {
|
|
310
|
+
test("signals the run's retention hook on the token BOTH sides derive", async () => {
|
|
311
|
+
// The token is the contract. `workflows/tokens.ts` is the one place it is
|
|
312
|
+
// spelled, which is what stops the body waiting on a string the tool never
|
|
313
|
+
// sends — a drift whose only symptom is `signal` answering false.
|
|
314
|
+
const workflows = stubWorkflows();
|
|
315
|
+
const signal = vi.fn(async () => true);
|
|
316
|
+
const ctx = createToolContext({ workflows: { ...workflows, signal } });
|
|
317
|
+
const result = await run("keep_transcript", { keep: true }, ctx);
|
|
318
|
+
|
|
319
|
+
expect(signal).toHaveBeenCalledWith(retentionToken(ctx.sessionId), { keep: true });
|
|
320
|
+
expect(result).toMatchObject({ answered: true, keep: true });
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("carries a DECLINE, not just an approval", async () => {
|
|
324
|
+
// Three outcomes, and this is the one a boolean gate loses if the tool only
|
|
325
|
+
// ever signals on yes: "delete it" has to reach the run before the window
|
|
326
|
+
// closes, or the caller waits two minutes for something they already said.
|
|
327
|
+
const signal = vi.fn(async () => true);
|
|
328
|
+
const ctx = createToolContext({ workflows: { ...stubWorkflows(), signal } });
|
|
329
|
+
await run("keep_transcript", { keep: false }, ctx);
|
|
330
|
+
expect(signal).toHaveBeenCalledWith(expect.any(String), { keep: false });
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("a token nobody holds is reported as SETTLED, not as a failure", async () => {
|
|
334
|
+
// The ordinary case: the window closed, or the caller answered a question
|
|
335
|
+
// nobody asked.
|
|
336
|
+
const signal = vi.fn(async () => false);
|
|
337
|
+
const ctx = createToolContext({ workflows: { ...stubWorkflows(), signal } });
|
|
338
|
+
const result = await run("keep_transcript", { keep: true }, ctx);
|
|
339
|
+
expect(result).toMatchObject({ answered: false, note: expect.stringContaining("settled") });
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
describe("cancel_recap", () => {
|
|
344
|
+
test("cancels the live run", async () => {
|
|
345
|
+
const workflows = stubWorkflows([createRunSnapshot({ workflow: "recap", status: "running" })]);
|
|
346
|
+
const result = await run("cancel_recap", {}, createToolContext({ workflows }));
|
|
347
|
+
expect(workflows.cancel).toHaveBeenCalledWith("wrun_1");
|
|
348
|
+
expect(result).toMatchObject({ cancelled: true });
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("says the transcript is LEFT BEHIND, because cancellation is not cooperative here", async () => {
|
|
352
|
+
// The one Temporal behaviour that did not port. Temporal delivers
|
|
353
|
+
// cancellation into the workflow, so the saga's catch runs; `cancel` here
|
|
354
|
+
// stops replaying the run, so the compensations never fire. A template that
|
|
355
|
+
// implied otherwise would be teaching the wrong thing.
|
|
356
|
+
const workflows = stubWorkflows([createRunSnapshot({ workflow: "recap", status: "running" })]);
|
|
357
|
+
const result = (await run("cancel_recap", {}, createToolContext({ workflows }))) as {
|
|
358
|
+
note: string;
|
|
359
|
+
};
|
|
360
|
+
expect(result.note).toContain("left behind");
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("a run that had already finished is reported honestly, not as a failure", async () => {
|
|
364
|
+
const workflows = stubWorkflows([
|
|
365
|
+
createRunSnapshot({ workflow: "recap", status: "completed", output: finishedOutput() }),
|
|
366
|
+
]);
|
|
367
|
+
vi.mocked(workflows.cancel).mockResolvedValue(false);
|
|
368
|
+
const result = await run("cancel_recap", {}, createToolContext({ workflows }));
|
|
369
|
+
expect(result).toMatchObject({ cancelled: false, note: "That one had already finished." });
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("says nothing was started when the key has no runs", async () => {
|
|
373
|
+
const workflows = stubWorkflows([]);
|
|
374
|
+
const result = await run("cancel_recap", {}, createToolContext({ workflows }));
|
|
375
|
+
expect(result).toMatchObject({ cancelled: false, note: "Nothing started yet." });
|
|
376
|
+
expect(workflows.cancel).not.toHaveBeenCalled();
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// ---- The steps --------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
/** A provider answering `body` with `status`, recording what it was asked. */
|
|
383
|
+
function stubProvider(body: unknown, status = 200) {
|
|
384
|
+
const calls: { url: string; init: RequestInit }[] = [];
|
|
385
|
+
vi.stubGlobal(
|
|
386
|
+
"fetch",
|
|
387
|
+
vi.fn(async (url: string, init: RequestInit = {}) => {
|
|
388
|
+
calls.push({ url, init });
|
|
389
|
+
return new Response(typeof body === "string" ? body : JSON.stringify(body), {
|
|
390
|
+
status,
|
|
391
|
+
headers: { "Content-Type": "application/json" },
|
|
392
|
+
});
|
|
393
|
+
}),
|
|
394
|
+
);
|
|
395
|
+
return calls;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
describe("submitRecording", () => {
|
|
399
|
+
beforeEach(() => {
|
|
400
|
+
// `stepEnv` falls back to the process env when no host has published one,
|
|
401
|
+
// which is exactly the case a spec is. `unstubEnvs` clears it per test.
|
|
402
|
+
vi.stubEnv("ASSEMBLYAI_API_KEY", "sk-test");
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("posts the recording and returns the job id", async () => {
|
|
406
|
+
const calls = stubProvider({ id: "t_1", status: "queued" });
|
|
407
|
+
expect(await submitRecording("https://example.com/a.mp3")).toEqual({ id: "t_1" });
|
|
408
|
+
|
|
409
|
+
const call = calls[0];
|
|
410
|
+
expect(call?.init.method).toBe("POST");
|
|
411
|
+
expect(JSON.parse(String(call?.init.body))).toMatchObject({
|
|
412
|
+
audio_url: "https://example.com/a.mp3",
|
|
413
|
+
});
|
|
414
|
+
// AssemblyAI's `authorization` takes the key RAW — no `Bearer` prefix,
|
|
415
|
+
// unlike the OpenAI-compatible LLM gateway `summarize` calls.
|
|
416
|
+
const headers = call?.init.headers as Record<string, string> | undefined;
|
|
417
|
+
expect(headers?.authorization).toBe("sk-test");
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("fails FATALLY on a bad key and plainly on a rate limit", async () => {
|
|
421
|
+
// The retry policy in one assertion: a 401 answers the same way on the
|
|
422
|
+
// fourth attempt, a 429 is what retries are for.
|
|
423
|
+
stubProvider({ error: "nope" }, 401);
|
|
424
|
+
await expect(submitRecording("https://example.com/a.mp3")).rejects.toThrow(/HTTP 401/);
|
|
425
|
+
stubProvider({ error: "slow down" }, 429);
|
|
426
|
+
await expect(submitRecording("https://example.com/a.mp3")).rejects.toThrow(/HTTP 429/);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("refuses a response that names no transcript id", async () => {
|
|
430
|
+
// Nothing downstream can poll without one, and a run that discovers that at
|
|
431
|
+
// the first poll has already lost the id it needed to compensate with.
|
|
432
|
+
stubProvider({ status: "queued" });
|
|
433
|
+
await expect(submitRecording("https://example.com/a.mp3")).rejects.toThrow(/transcript id/);
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
describe("checkTranscript", () => {
|
|
438
|
+
beforeEach(() => {
|
|
439
|
+
vi.stubEnv("ASSEMBLYAI_API_KEY", "sk-test");
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("reports a completed job with its text and duration", async () => {
|
|
443
|
+
stubProvider({ status: "completed", text: "Hello there.", audio_duration: 254 });
|
|
444
|
+
expect(await checkTranscript("t_1")).toEqual({
|
|
445
|
+
status: "completed",
|
|
446
|
+
text: "Hello there.",
|
|
447
|
+
audioDuration: 254,
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test("carries the provider's own failure message through", async () => {
|
|
452
|
+
stubProvider({ status: "error", error: "Transcoding failed" });
|
|
453
|
+
expect(await checkTranscript("t_1")).toMatchObject({
|
|
454
|
+
status: "error",
|
|
455
|
+
error: "Transcoding failed",
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test("omits absent fields rather than setting them to undefined", async () => {
|
|
460
|
+
// `exactOptionalPropertyTypes` makes those different types, and the result
|
|
461
|
+
// crosses a queue — a key set to `undefined` does not survive the trip.
|
|
462
|
+
stubProvider({ status: "processing" });
|
|
463
|
+
expect(Object.keys(await checkTranscript("t_1"))).toEqual(["status"]);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("refuses a status it does not recognise instead of polling forever", async () => {
|
|
467
|
+
// The loop's exit conditions are `completed` and `error`; an unknown status
|
|
468
|
+
// is neither, so it would poll to the bound and then fail with the wrong
|
|
469
|
+
// reason.
|
|
470
|
+
stubProvider({ status: "transcribing" });
|
|
471
|
+
await expect(checkTranscript("t_1")).rejects.toThrow(/unknown transcript status/);
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
describe("discardTranscript — the compensation", () => {
|
|
476
|
+
beforeEach(() => {
|
|
477
|
+
vi.stubEnv("ASSEMBLYAI_API_KEY", "sk-test");
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("deletes the transcript this run created", async () => {
|
|
481
|
+
const calls = stubProvider({ id: "t_1" });
|
|
482
|
+
await expect(discardTranscript("t_1")).resolves.toBeUndefined();
|
|
483
|
+
expect(calls[0]?.init.method).toBe("DELETE");
|
|
484
|
+
expect(calls[0]?.url).toContain("/t_1");
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test("treats a 404 as success, because an undo must be safe to run twice", async () => {
|
|
488
|
+
// The property every compensation needs: a replay re-enters a world where
|
|
489
|
+
// the undo may already have happened, and an undo that fails there would
|
|
490
|
+
// fail the unwind for having succeeded.
|
|
491
|
+
stubProvider("", 404);
|
|
492
|
+
await expect(discardTranscript("t_1")).resolves.toBeUndefined();
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test("still fails on a real error, so the DevKit retries it", async () => {
|
|
496
|
+
stubProvider("", 503);
|
|
497
|
+
await expect(discardTranscript("t_1")).rejects.toThrow(/HTTP 503/);
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
describe("summarize", () => {
|
|
502
|
+
/** A finished transcript, typed rather than cast — the shape a step really gets. */
|
|
503
|
+
function transcript(over: Partial<TranscriptState> = {}): TranscriptState {
|
|
504
|
+
return { status: "completed", text: "We talked about smoke.", audioDuration: 254, ...over };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
beforeEach(() => {
|
|
508
|
+
vi.stubEnv("ASSEMBLYAI_API_KEY", "sk-test");
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("returns the recap the model produced, with the recording's length in minutes", async () => {
|
|
512
|
+
stubGateway('{"headline":"Smoke","points":["a","b","c"],"spoken":"Smoke drifted east."}');
|
|
513
|
+
expect(await summarize("https://example.com/a.mp3", transcript())).toEqual({
|
|
514
|
+
url: "https://example.com/a.mp3",
|
|
515
|
+
headline: "Smoke",
|
|
516
|
+
points: ["a", "b", "c"],
|
|
517
|
+
spoken: "Smoke drifted east.",
|
|
518
|
+
// Rounded from the provider's seconds — a voice reply says "four minutes",
|
|
519
|
+
// never "254 seconds".
|
|
520
|
+
minutes: 4,
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test("unwraps a fenced reply rather than failing on it", async () => {
|
|
525
|
+
stubGateway('```json\n{"headline":"H","points":["a"],"spoken":"S."}\n```');
|
|
526
|
+
expect((await summarize("https://x/a.mp3", transcript())).headline).toBe("H");
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test("throws PLAINLY when the model answered with prose, so the step retries", async () => {
|
|
530
|
+
// The distinction that is the whole retry policy: a model that ignored the
|
|
531
|
+
// format may well obey on the next attempt, where a 401 will not.
|
|
532
|
+
stubGateway("Here is a recap of the recording.");
|
|
533
|
+
// The SDK's message, not this template's: `stepGenerateJson` owns the
|
|
534
|
+
// unwrap/parse/validate chain now, and a plain throw is what the DevKit
|
|
535
|
+
// retries.
|
|
536
|
+
await expect(summarize("https://x/a.mp3", transcript())).rejects.toThrow(/Expected JSON/);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test("rejects a reply missing the spoken sentence as firmly as no JSON at all", async () => {
|
|
540
|
+
// Without it the announced turn has nothing to read, which is the one field
|
|
541
|
+
// this template's output exists for.
|
|
542
|
+
stubGateway('{"headline":"H","points":["a"]}');
|
|
543
|
+
await expect(summarize("https://x/a.mp3", transcript())).rejects.toThrow(/did not match/);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
test("fails FATALLY on a transcript with no speech in it", async () => {
|
|
547
|
+
// A completed transcript holds the same nothing on every attempt — silence,
|
|
548
|
+
// or a file with no speech. Retrying it five times buys nothing.
|
|
549
|
+
stubGateway('{"headline":"H","points":["a"],"spoken":"S."}');
|
|
550
|
+
await expect(summarize("https://x/a.mp3", transcript({ text: " " }))).rejects.toThrow(
|
|
551
|
+
/no speech/,
|
|
552
|
+
);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test("fails FATALLY with no API key rather than retrying five times", async () => {
|
|
556
|
+
vi.stubEnv("ASSEMBLYAI_API_KEY", "");
|
|
557
|
+
stubGateway('{"headline":"H","points":["a"],"spoken":"S."}');
|
|
558
|
+
await expect(summarize("https://x/a.mp3", transcript())).rejects.toThrow(/ASSEMBLYAI_API_KEY/);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test("retries beyond the default, because a rate limit and a bad format both happen", () => {
|
|
562
|
+
expect(summarize.maxRetries).toBeGreaterThan(3);
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
// ---- The body's helpers -----------------------------------------------------
|
|
567
|
+
|
|
568
|
+
describe("awaitTranscript — the polling port", () => {
|
|
569
|
+
beforeEach(() => {
|
|
570
|
+
vi.stubEnv("ASSEMBLYAI_API_KEY", "sk-test");
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
/** A provider whose status endpoint answers `statuses` in order. */
|
|
574
|
+
function stubStatuses(statuses: readonly Record<string, unknown>[]) {
|
|
575
|
+
let call = 0;
|
|
576
|
+
vi.stubGlobal(
|
|
577
|
+
"fetch",
|
|
578
|
+
vi.fn(async () => {
|
|
579
|
+
const body = statuses[Math.min(call, statuses.length - 1)];
|
|
580
|
+
call += 1;
|
|
581
|
+
return new Response(JSON.stringify(body), {
|
|
582
|
+
status: 200,
|
|
583
|
+
headers: { "Content-Type": "application/json" },
|
|
584
|
+
});
|
|
585
|
+
}),
|
|
586
|
+
);
|
|
587
|
+
return () => call;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
test("keeps polling while the job is queued or processing", async () => {
|
|
591
|
+
const polls = stubStatuses([
|
|
592
|
+
{ status: "queued" },
|
|
593
|
+
{ status: "processing" },
|
|
594
|
+
{ status: "completed", text: "Done.", audio_duration: 60 },
|
|
595
|
+
]);
|
|
596
|
+
const state = await awaitTranscript("t_1");
|
|
597
|
+
expect(state).toMatchObject({ status: "completed", text: "Done." });
|
|
598
|
+
expect(polls()).toBe(3);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test("stops on the provider's own terminal failure instead of waiting it out", async () => {
|
|
602
|
+
// `error` is terminal: polling a failed job to the bound would spend twenty
|
|
603
|
+
// minutes learning what the first answer already said.
|
|
604
|
+
const polls = stubStatuses([{ status: "error", error: "Transcoding failed" }]);
|
|
605
|
+
await expect(awaitTranscript("t_1")).rejects.toThrow(/Transcoding failed/);
|
|
606
|
+
expect(polls()).toBe(1);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test("gives up at the bound rather than polling a stuck job forever", async () => {
|
|
610
|
+
stubStatuses([{ status: "processing" }]);
|
|
611
|
+
await expect(awaitTranscript("t_1")).rejects.toThrow(/Gave up/);
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
describe("askWhetherToKeep — the expense port", () => {
|
|
616
|
+
beforeEach(() => {
|
|
617
|
+
vi.stubEnv("ASSEMBLYAI_API_KEY", "sk-test");
|
|
618
|
+
// The window has NOT elapsed unless a test says so. A `sleep` that resolves
|
|
619
|
+
// would race the hook's own answer, and which one won would come down to
|
|
620
|
+
// microtask order rather than to the branch under test.
|
|
621
|
+
vi.mocked(sleep).mockReturnValue(new Promise<void>(() => undefined));
|
|
622
|
+
vi.mocked(createHook).mockReturnValue(hookAnswering(undefined));
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* A hook that resolves with `payload`, or never — which is what the timeout
|
|
627
|
+
* branch has to see.
|
|
628
|
+
*
|
|
629
|
+
* Built by hanging the hook's own members on a REAL promise rather than by
|
|
630
|
+
* writing a `then` property: a `Hook` is a thenable, and a hand-written one
|
|
631
|
+
* is both a lint finding and a worse model of the thing.
|
|
632
|
+
*
|
|
633
|
+
* `createHook()` throws outside a run, so this is the only way to reach the
|
|
634
|
+
* gate's branching at all. What it pins is the three OUTCOMES and the safe
|
|
635
|
+
* default; suspension, token registration and replay are not testable here
|
|
636
|
+
* and are not claimed to be.
|
|
637
|
+
*/
|
|
638
|
+
function hookAnswering(
|
|
639
|
+
payload: { keep: boolean } | undefined,
|
|
640
|
+
onClaim: () => void = () => undefined,
|
|
641
|
+
): Hook<{ keep: boolean }> {
|
|
642
|
+
const settled: Promise<{ keep: boolean }> =
|
|
643
|
+
payload === undefined ? new Promise(() => undefined) : Promise.resolve(payload);
|
|
644
|
+
return Object.assign(settled, {
|
|
645
|
+
token: "retention:stub",
|
|
646
|
+
getConflict: async () => {
|
|
647
|
+
onClaim();
|
|
648
|
+
return null;
|
|
649
|
+
},
|
|
650
|
+
dispose: () => undefined,
|
|
651
|
+
[Symbol.dispose]: () => undefined,
|
|
652
|
+
[Symbol.asyncIterator]: async function* () {
|
|
653
|
+
yield await settled;
|
|
654
|
+
},
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
test("keeps the transcript when the caller says to, and deletes nothing", async () => {
|
|
659
|
+
vi.mocked(createHook).mockReturnValue(hookAnswering({ keep: true }));
|
|
660
|
+
const fetchSpy = vi.fn();
|
|
661
|
+
vi.stubGlobal("fetch", fetchSpy);
|
|
662
|
+
|
|
663
|
+
const compensations = [{ label: "transcript t_1", undo: async () => undefined }];
|
|
664
|
+
expect(await askWhetherToKeep("s_1", "t_1", compensations)).toEqual({
|
|
665
|
+
kept: true,
|
|
666
|
+
answered: true,
|
|
667
|
+
});
|
|
668
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
669
|
+
// The undo stays on the stack: a later failure still has something to reverse.
|
|
670
|
+
expect(compensations).toHaveLength(1);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
test("deletes on a DECLINE, and drops the undo it just performed", async () => {
|
|
674
|
+
vi.mocked(createHook).mockReturnValue(hookAnswering({ keep: false }));
|
|
675
|
+
const calls = stubProvider({ id: "t_1" });
|
|
676
|
+
|
|
677
|
+
const compensations = [{ label: "transcript t_1", undo: async () => undefined }];
|
|
678
|
+
expect(await askWhetherToKeep("s_1", "t_1", compensations)).toEqual({
|
|
679
|
+
kept: false,
|
|
680
|
+
answered: true,
|
|
681
|
+
});
|
|
682
|
+
expect(calls[0]?.init.method).toBe("DELETE");
|
|
683
|
+
// Leaving it would be harmless — the undo tolerates a 404 — and would still
|
|
684
|
+
// narrate an unwind that reverses something already gone.
|
|
685
|
+
expect(compensations).toHaveLength(0);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
test("DELETES when nobody answers, which is what makes the window mean anything", async () => {
|
|
689
|
+
// The safe default, and the whole reason the gate is a gate: a no-answer
|
|
690
|
+
// branch that kept the data would be a prompt with a grace period.
|
|
691
|
+
vi.mocked(sleep).mockResolvedValue(undefined);
|
|
692
|
+
const calls = stubProvider({ id: "t_1" });
|
|
693
|
+
|
|
694
|
+
expect(await askWhetherToKeep("s_1", "t_1", [])).toEqual({ kept: false, answered: false });
|
|
695
|
+
expect(calls[0]?.init.method).toBe("DELETE");
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
test("claims the token BEFORE the caller is asked to answer it", async () => {
|
|
699
|
+
// `createHook()` registers nothing until the workflow suspends, so an answer
|
|
700
|
+
// sent before the claim lands is answered "nobody is listening" — which is
|
|
701
|
+
// indistinguishable from being late.
|
|
702
|
+
const order: string[] = [];
|
|
703
|
+
vi.mocked(createHook).mockReturnValue(
|
|
704
|
+
hookAnswering({ keep: true }, () => order.push("claimed")),
|
|
705
|
+
);
|
|
706
|
+
vi.stubGlobal(
|
|
707
|
+
"fetch",
|
|
708
|
+
vi.fn(async () => {
|
|
709
|
+
order.push("asked");
|
|
710
|
+
return new Response("{}", { status: 200 });
|
|
711
|
+
}),
|
|
712
|
+
);
|
|
713
|
+
|
|
714
|
+
await askWhetherToKeep("s_1", "t_1", []);
|
|
715
|
+
expect(order[0]).toBe("claimed");
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
describe("compensate — the saga port", () => {
|
|
720
|
+
test("unwinds newest-first, which is the order acquisitions were stacked in", async () => {
|
|
721
|
+
// `recapFlow` pushes with `unshift`, so the list is already newest-first and
|
|
722
|
+
// this walks it forwards. A dependency acquired later has to come off first.
|
|
723
|
+
const order: string[] = [];
|
|
724
|
+
await compensate(
|
|
725
|
+
[
|
|
726
|
+
{ label: "second", undo: async () => void order.push("second") },
|
|
727
|
+
{ label: "first", undo: async () => void order.push("first") },
|
|
728
|
+
],
|
|
729
|
+
"because",
|
|
730
|
+
);
|
|
731
|
+
expect(order).toEqual(["second", "first"]);
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
test("a failing undo does not stop the ones behind it", async () => {
|
|
735
|
+
// Ported deliberately from Temporal's `compensate`, which swallows: the run
|
|
736
|
+
// already failed for a reason the caller needs, and a second-order undo
|
|
737
|
+
// failure must not replace it or strand the rest of the stack.
|
|
738
|
+
const order: string[] = [];
|
|
739
|
+
await expect(
|
|
740
|
+
compensate(
|
|
741
|
+
[
|
|
742
|
+
{
|
|
743
|
+
label: "broken",
|
|
744
|
+
undo: async () => {
|
|
745
|
+
throw new Error("the provider declined the delete");
|
|
746
|
+
},
|
|
747
|
+
},
|
|
748
|
+
{ label: "fine", undo: async () => void order.push("fine") },
|
|
749
|
+
],
|
|
750
|
+
"because",
|
|
751
|
+
),
|
|
752
|
+
).resolves.toBeUndefined();
|
|
753
|
+
expect(order).toEqual(["fine"]);
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
test("does nothing at all when nothing was acquired", async () => {
|
|
757
|
+
// The case where the FIRST step failed: there is nothing to reverse, and a
|
|
758
|
+
// run that narrated an unwind it did not perform would be lying to the
|
|
759
|
+
// caller reading its progress.
|
|
760
|
+
await expect(compensate([], "nothing was acquired")).resolves.toBeUndefined();
|
|
761
|
+
});
|
|
762
|
+
});
|