@alexkroman1/aai-cli 13.2.0 → 13.3.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/bin.mjs +1 -1
- package/dist/{_agent-BTwZJu4P.mjs → _agent-Clbq3a4h.mjs} +2 -2
- package/dist/{_bundler-C31rlcYz.mjs → _bundler-DEkjO1d8.mjs} +3 -3
- package/dist/{_config-CmJOFsAP.mjs → _config-ZY2euhdh.mjs} +7 -4
- package/dist/_config.d.ts +25 -1
- package/dist/{_dev-server-DWvkNoIS.mjs → _dev-server-C13xuGB1.mjs} +13 -13
- package/dist/_e2e-test-utils.d.ts +15 -7
- package/dist/{_init-BURbdHNM.mjs → _init-Ci4QGjp8.mjs} +58 -10
- package/dist/{_output-CKkmWs7i.mjs → _output-DbJt9gsl.mjs} +34 -3
- package/dist/_output.d.ts +17 -1
- package/dist/{_preflight-Co1G9Jww.mjs → _preflight-B2laWPdU.mjs} +1 -1
- package/dist/{_server-common-6e0QI4mq.mjs → _server-common-BV4_CrP3.mjs} +3 -3
- package/dist/{_slug-api-CfGQq1Tt.mjs → _slug-api-DlKpj91X.mjs} +3 -3
- package/dist/{_studio-B1waXMuR.mjs → _studio-DTi3EdXQ.mjs} +3 -3
- package/dist/{_templates-0Zr2z6yA.mjs → _templates-V4l5sZJy.mjs} +2 -2
- package/dist/{_typecheck-gate-BXvUNnfs.mjs → _typecheck-gate-sV4N5F93.mjs} +3 -3
- package/dist/{_ui-u7T4YooX.mjs → _ui-DCt4qZrk.mjs} +1 -1
- package/dist/{_utils-B8QmtFhK.mjs → _utils-Dhd058D9.mjs} +1 -1
- package/dist/{_vite-env-D9Qj6KPX.mjs → _vite-env-kejF0p2L.mjs} +1 -1
- package/dist/{_workflow-determinism-f9J-5Zlj.mjs → _workflow-determinism-C9l8uC2R.mjs} +1 -1
- package/dist/{build-BLlLOrng.mjs → build-CWyFGe-W.mjs} +11 -8
- package/dist/cli.d.ts +2 -1
- package/dist/cli.mjs +120 -46
- package/dist/{client-bundler-j5c46x0G.mjs → client-bundler-CT-_26c5.mjs} +4 -4
- package/dist/client-bundler.mjs +1 -1
- package/dist/{delete-X_CRgCf6.mjs → delete-RqlQZ6Y_.mjs} +6 -6
- package/dist/{deploy-BzZ-r4iM.mjs → deploy-B7f3CK7v.mjs} +13 -13
- package/dist/{dev-BDykFvcc.mjs → dev-DamXieW_.mjs} +5 -5
- package/dist/{eval-0qEOdm0l.mjs → eval-h3DTVfRw.mjs} +27 -11
- package/dist/eval.d.ts +2 -0
- package/dist/{init-BtJI306C.mjs → init-D-vFrV8h.mjs} +7 -7
- package/dist/{login-DeUETobb.mjs → login-CDiemNmC.mjs} +5 -5
- package/dist/{logs-hWnJ66Bl.mjs → logs-DGwPNCzB.mjs} +4 -4
- package/dist/project-config.mjs +1 -1
- package/dist/scaffold/.env.example +17 -1
- package/dist/scaffold/package.json +6 -5
- package/dist/secret-V1w7y9-j.mjs +162 -0
- package/dist/secret.d.ts +41 -8
- package/dist/{studio-BxAS_FQQ.mjs → studio-BpMCfUNx.mjs} +9 -9
- package/dist/templates/briefing-desk/agent.test.ts +7 -3
- package/dist/templates/call-audit/agent.test.ts +14 -3
- package/dist/templates/call-audit/client.tsx +14 -17
- package/dist/templates/embedded-assets/agent.test.ts +7 -1
- package/dist/templates/health-assistant/agent.test.ts +8 -4
- package/dist/templates/link-digest/agent.test.ts +7 -1
- package/dist/templates/link-digest/client.tsx +17 -21
- package/dist/templates/night-owl/agent.test.ts +6 -1
- package/dist/templates/pipeline-simple/agent.test.ts +31 -14
- package/dist/templates/podcast-digest/agent.test.ts +13 -3
- package/dist/templates/podcast-digest/client.tsx +12 -15
- package/dist/templates/recap-workflow/agent.test.ts +22 -9
- package/dist/templates/redline/agent.test.ts +7 -1
- package/dist/templates/redline/client.tsx +12 -15
- package/dist/templates/research-workflow/agent.test.ts +7 -1
- package/dist/templates/retail/registry.test.ts +99 -58
- package/dist/templates/simple/agent.test.ts +39 -6
- package/dist/templates/spoken-summary/agent.test.ts +12 -3
- package/dist/templates/spoken-summary/client.tsx +14 -18
- package/dist/templates/transcription-workflow/agent.test.ts +16 -8
- package/dist/templates/transcription-workflow/client.tsx +19 -26
- package/dist/test-guenJUxi.mjs +255 -0
- package/dist/test.d.ts +97 -31
- package/dist/typecheck.mjs +2 -2
- package/dist/worker-bundler.mjs +3 -3
- package/dist/{workflow-BBu5oEDw.mjs → workflow-BZyKLkhV.mjs} +5 -5
- package/package.json +10 -10
- package/dist/bin.d.mts +0 -2
- package/dist/secret-B-3T5tvr.mjs +0 -61
- package/dist/test-CiLab-AA.mjs +0 -182
- package/dist/tsdown.config.d.ts +0 -2
|
@@ -103,15 +103,25 @@ const slackInput = (slackWebhookUrl: string) => ({
|
|
|
103
103
|
|
|
104
104
|
describe("the declaration", () => {
|
|
105
105
|
test("is a workflow app with one workflow and a static page", () => {
|
|
106
|
-
|
|
107
|
-
|
|
106
|
+
// Not the agent's name: renaming it is an invited edit, and pinning the
|
|
107
|
+
// literal here would fail a build in somebody else's project.
|
|
108
|
+
expect(agentDef.name).toBeTruthy();
|
|
109
|
+
// `toContain` rather than an exact key list: adding a second workflow is an
|
|
110
|
+
// invited edit and must not redden a test the author did not write. The
|
|
111
|
+
// NAME is still pinned, deliberately — the page starts a run by this
|
|
112
|
+
// string, so renaming the key is a runtime 400 rather than a compile
|
|
113
|
+
// error, and this pin is the only thing that says so. Rename it here and
|
|
114
|
+
// in `client.tsx` together.
|
|
115
|
+
expect(Object.keys(agentDef.workflows ?? {})).toContain("dailyDigest");
|
|
108
116
|
expect(agentDef.workflows?.dailyDigest).toBe(dailyDigest);
|
|
109
117
|
});
|
|
110
118
|
|
|
111
119
|
test("names the one credential its steps read", () => {
|
|
112
120
|
// A workflow app declares no providers, so nothing else in the config can
|
|
113
121
|
// name this — which is what makes a deploy able to check for it.
|
|
114
|
-
|
|
122
|
+
// `toContain`, so a step of your own that reads a second credential can be
|
|
123
|
+
// declared beside this one without failing a test you did not write.
|
|
124
|
+
expect(agentDef.requiredEnv).toContain("ASSEMBLYAI_API_KEY");
|
|
115
125
|
});
|
|
116
126
|
});
|
|
117
127
|
|
|
@@ -17,12 +17,13 @@
|
|
|
17
17
|
* minutes, this one loses the only Stop button a thirty-digest commitment has,
|
|
18
18
|
* and the run is invisible from then on to everything but `curl`.
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* Wake and Cancel buttons bound to
|
|
20
|
+
* A correlation KEY is the handle that survives, and `useWorkflowSubmit` looks
|
|
21
|
+
* that key's newest run up as it mounts, so a later load lands on the same
|
|
22
|
+
* count, the same newest line, and the same Wake and Cancel buttons bound to
|
|
23
|
+
* the same run.
|
|
23
24
|
*
|
|
24
|
-
* **
|
|
25
|
-
*
|
|
25
|
+
* **This is the one workflow app that passes a `key` of its own, and it is
|
|
26
|
+
* `useRunKey({ storage: "local" })`.** Its siblings let the hook mint one into
|
|
26
27
|
* `sessionStorage`, which dies with the tab and covers exactly the interruption
|
|
27
28
|
* they have — a reload, a same-tab navigation, a crashed tab. A schedule
|
|
28
29
|
* outlives all of that by design: closing the browser on Tuesday and coming
|
|
@@ -42,9 +43,9 @@
|
|
|
42
43
|
* URL would carry a credential into a lookup token — which is why the
|
|
43
44
|
* platform stopped writing keys to the operator's log.
|
|
44
45
|
*
|
|
45
|
-
* A real app with accounts passes the account's own id instead, and
|
|
46
|
-
* schedule follows the person to a new device — a promise only a login
|
|
47
|
-
* keep.
|
|
46
|
+
* A real app with accounts passes the account's own id as `key` instead, and
|
|
47
|
+
* then the schedule follows the person to a new device — a promise only a login
|
|
48
|
+
* can keep.
|
|
48
49
|
*
|
|
49
50
|
* ## The form is DECLARED, not written
|
|
50
51
|
*
|
|
@@ -135,15 +136,11 @@ export function App() {
|
|
|
135
136
|
// `run.output` instead of `unknown`. `error` is the agent's own sentence for a
|
|
136
137
|
// rejected input — better copy than anything this page could write, and the
|
|
137
138
|
// reason there is no `try`/`catch` here.
|
|
139
|
+
// The key REPLACES the tab-scoped one the hook would mint; the lookup that
|
|
140
|
+
// reads it back on the next load happens either way.
|
|
138
141
|
const { submitForm, run, pending, error, wake, cancel } = useWorkflowSubmit<typeof dailyDigest>(
|
|
139
142
|
WORKFLOW,
|
|
140
|
-
{
|
|
141
|
-
// Recorded with the run, and read back on the next load. Neither half is
|
|
142
|
-
// useful alone: without the key there is nothing to find the run by, and
|
|
143
|
-
// without `recover` the key is only ever written.
|
|
144
|
-
key,
|
|
145
|
-
recover: true,
|
|
146
|
-
},
|
|
143
|
+
{ key },
|
|
147
144
|
);
|
|
148
145
|
|
|
149
146
|
return (
|
|
@@ -116,7 +116,13 @@ function finishedOutput(over: { kept?: boolean; answered?: boolean } = {}) {
|
|
|
116
116
|
|
|
117
117
|
describe("the agent declares its workflow", () => {
|
|
118
118
|
test("under the name ctx.workflows.start resolves it by", () => {
|
|
119
|
-
|
|
119
|
+
// `toContain` rather than an exact key list: a second workflow is an
|
|
120
|
+
// invited edit and must not redden a test the author did not write. The
|
|
121
|
+
// NAME is still pinned, deliberately — this key is a STRING to everything
|
|
122
|
+
// outside this file (the REST route, `ctx.workflows.get`, a schedule), so
|
|
123
|
+
// renaming it is a runtime 404 rather than a compile error, and nothing
|
|
124
|
+
// else says so.
|
|
125
|
+
expect(Object.keys(agentDef.workflows ?? {})).toContain("recap");
|
|
120
126
|
expect(agentDef.workflows?.recap).toBe(recap);
|
|
121
127
|
});
|
|
122
128
|
|
|
@@ -143,14 +149,21 @@ describe("the agent declares its workflow", () => {
|
|
|
143
149
|
});
|
|
144
150
|
|
|
145
151
|
test("discovers every tool in tools/, by file name", () => {
|
|
146
|
-
// Discovered, not declared: every name here is a file in `tools/`.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
152
|
+
// Discovered, not declared: every name here is a file in `tools/`. Asserted
|
|
153
|
+
// with `arrayContaining`, because dropping a file into `tools/` is the
|
|
154
|
+
// cheapest edit this template invites and an exact list would turn it into
|
|
155
|
+
// a failing test in somebody else's project. What still fails is a tool
|
|
156
|
+
// going MISSING — which is what a broken discovery looks like, and it looks
|
|
157
|
+
// identical to a template that never had tools.
|
|
158
|
+
expect(Object.keys(agentDef.tools)).toEqual(
|
|
159
|
+
expect.arrayContaining([
|
|
160
|
+
"cancel_recap",
|
|
161
|
+
"keep_transcript",
|
|
162
|
+
"recap_progress",
|
|
163
|
+
"recap_status",
|
|
164
|
+
"request_recap",
|
|
165
|
+
]),
|
|
166
|
+
);
|
|
154
167
|
});
|
|
155
168
|
});
|
|
156
169
|
|
|
@@ -46,7 +46,13 @@ describe("the agent declares itself a workflow app", () => {
|
|
|
46
46
|
test("under the name the page starts a run by", () => {
|
|
47
47
|
// `useWorkflowSubmit("redline")` in client.tsx names this key. Nothing else
|
|
48
48
|
// records it, so a rename here is a 400 there rather than a compile error.
|
|
49
|
-
|
|
49
|
+
// `toContain` rather than an exact key list: adding a second workflow is an
|
|
50
|
+
// invited edit and must not redden a test the author did not write. The
|
|
51
|
+
// NAME is still pinned, deliberately — the page starts a run by this
|
|
52
|
+
// string, so renaming the key is a runtime 400 rather than a compile
|
|
53
|
+
// error, and this pin is the only thing that says so. Rename it here and
|
|
54
|
+
// in `client.tsx` together.
|
|
55
|
+
expect(Object.keys(agentDef.workflows ?? {})).toContain("redline");
|
|
50
56
|
expect(agentDef.workflows?.redline).toBe(redline);
|
|
51
57
|
});
|
|
52
58
|
|
|
@@ -31,11 +31,13 @@
|
|
|
31
31
|
* in React state — so a refresh lost it while the loop carried on writing,
|
|
32
32
|
* grading and revising without anywhere to report to. On a desk whose whole
|
|
33
33
|
* subject is a loop that runs several long-form model calls, that is the one
|
|
34
|
-
* failure
|
|
35
|
-
*
|
|
36
|
-
* Clear button are
|
|
34
|
+
* failure the hook now covers on its own: `useWorkflowSubmit` records every run
|
|
35
|
+
* under a correlation KEY it mints for this page and asks for that key's newest
|
|
36
|
+
* run as it mounts, so the draft, the critique trail and the Clear button are
|
|
37
|
+
* all there again with nothing written here.
|
|
37
38
|
*
|
|
38
|
-
* **The key is opaque and lives in `sessionStorage`, and the brief is
|
|
39
|
+
* **The key it mints is opaque and lives in `sessionStorage`, and the brief is
|
|
40
|
+
* why this page wants exactly that one.** A
|
|
39
41
|
* `?key=` parameter in the page's own URL would survive more — a new tab, a
|
|
40
42
|
* bookmark, a link sent to the person who asked for the piece — and that is
|
|
41
43
|
* exactly what it must not do here. There is no per-user filtering behind
|
|
@@ -48,7 +50,9 @@
|
|
|
48
50
|
*
|
|
49
51
|
* Deriving the key from the brief is worse again: two people briefing the same
|
|
50
52
|
* thing would recover each other's runs, and the key would then carry what they
|
|
51
|
-
* typed into a lookup token the platform deliberately stopped logging.
|
|
53
|
+
* typed into a lookup token the platform deliberately stopped logging. Both are
|
|
54
|
+
* things a page could still ask for by passing its own `key`, and this one has
|
|
55
|
+
* no reason to.
|
|
52
56
|
*/
|
|
53
57
|
|
|
54
58
|
import "@alexkroman1/aai-ui/styles.css";
|
|
@@ -60,7 +64,6 @@ import {
|
|
|
60
64
|
page,
|
|
61
65
|
SubmitButton,
|
|
62
66
|
TextAreaField,
|
|
63
|
-
useRunKey,
|
|
64
67
|
useWorkflowSubmit,
|
|
65
68
|
WORKFLOW_STATUS_LABELS,
|
|
66
69
|
WorkflowFields,
|
|
@@ -121,19 +124,13 @@ export function toInput(values: FormValues): WorkflowInputOf<typeof redline> {
|
|
|
121
124
|
}
|
|
122
125
|
|
|
123
126
|
function RedlineDesk() {
|
|
124
|
-
// This tab's handle on its own drafts — opaque, short, and remembered for the
|
|
125
|
-
// next load, which is what `recover` produces to find the run again.
|
|
126
|
-
const key = useRunKey();
|
|
127
127
|
// Did THIS load start the run? A reload cannot have, and that is the only way
|
|
128
128
|
// the page can tell "writing what you just briefed" from "picking up where
|
|
129
129
|
// you left off" — the hook reports the run, not who asked for it.
|
|
130
130
|
const [startedHere, setStartedHere] = useState(false);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
key,
|
|
135
|
-
recover: true,
|
|
136
|
-
});
|
|
131
|
+
// The reload is covered by the hook's own key — see the module doc for why
|
|
132
|
+
// this desk wants the tab-scoped one it mints rather than a key of its own.
|
|
133
|
+
const { submit, run, pending, error, reset } = useWorkflowSubmit<typeof redline>(WORKFLOW);
|
|
137
134
|
|
|
138
135
|
return (
|
|
139
136
|
<main className="mx-auto flex max-w-2xl flex-col gap-8 p-8">
|
|
@@ -106,7 +106,13 @@ function stubWorkflows(runs: WorkflowRunSnapshot[] = []): WorkflowClient {
|
|
|
106
106
|
|
|
107
107
|
describe("the agent declares its workflow", () => {
|
|
108
108
|
test("under the name ctx.workflows.start resolves it by", () => {
|
|
109
|
-
|
|
109
|
+
// `toContain` rather than an exact key list: a second workflow is an
|
|
110
|
+
// invited edit and must not redden a test the author did not write. The
|
|
111
|
+
// NAME is still pinned, deliberately — this key is a STRING to everything
|
|
112
|
+
// outside this file (the REST route, `ctx.workflows.get`, a schedule), so
|
|
113
|
+
// renaming it is a runtime 404 rather than a compile error, and nothing
|
|
114
|
+
// else says so.
|
|
115
|
+
expect(Object.keys(agentDef.workflows ?? {})).toContain("research");
|
|
110
116
|
expect(agentDef.workflows?.research).toBe(research);
|
|
111
117
|
});
|
|
112
118
|
|
|
@@ -1,21 +1,6 @@
|
|
|
1
1
|
import { isToolFailure, type ToolContext } from "@alexkroman1/aai";
|
|
2
2
|
import { createToolContext, deployedAgent } from "@alexkroman1/aai/testing";
|
|
3
3
|
import { describe, expect, test } from "vitest";
|
|
4
|
-
import { callFlow, retailSlot } from "./store.ts";
|
|
5
|
-
|
|
6
|
-
/** Tools that legitimately run before the caller is identified — the six
|
|
7
|
-
* declaring `when: BEFORE_TRANSFER`. Everything else must refuse. Listed here
|
|
8
|
-
* so ADDING an unauthenticated tool is a deliberate edit to this file, not a
|
|
9
|
-
* silent gap. */
|
|
10
|
-
const PUBLIC_TOOLS = new Set([
|
|
11
|
-
"find_user_id_by_email",
|
|
12
|
-
"find_user_id_by_name_zip",
|
|
13
|
-
"get_product_details",
|
|
14
|
-
"get_item_details",
|
|
15
|
-
"list_all_product_types",
|
|
16
|
-
"transfer_to_human_agents",
|
|
17
|
-
]);
|
|
18
|
-
|
|
19
4
|
/**
|
|
20
5
|
* The def a DEPLOYED agent runs, lowered BY HAND — the one place in the
|
|
21
6
|
* templates that still does.
|
|
@@ -27,6 +12,7 @@ const PUBLIC_TOOLS = new Set([
|
|
|
27
12
|
* whose runner is not vitest, and so cannot register the plugin.
|
|
28
13
|
*/
|
|
29
14
|
import authoredAgent from "./agent.ts";
|
|
15
|
+
import { callFlow, gateFor, retailSlot } from "./store.ts";
|
|
30
16
|
import systemPrompt from "./system-prompt.md?raw";
|
|
31
17
|
|
|
32
18
|
const retailAgent = deployedAgent(authoredAgent, {
|
|
@@ -36,6 +22,39 @@ const retailAgent = deployedAgent(authoredAgent, {
|
|
|
36
22
|
|
|
37
23
|
const registry = Object.entries(retailAgent.tools);
|
|
38
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Whether `name`'s gate admits the pre-identification state — i.e. whether it
|
|
27
|
+
* is one of the tools that legitimately runs before the caller is identified.
|
|
28
|
+
*
|
|
29
|
+
* ASKED OF THE REGISTRY, not read off a list here. This used to be a hardcoded
|
|
30
|
+
* six-name set, which meant a tool you add is classified by a file you did not
|
|
31
|
+
* write: declare `when: BEFORE_TRANSFER` on a new catalogue read and the
|
|
32
|
+
* sweeps below would have demanded it refuse. `retailTool` records every gate
|
|
33
|
+
* in `TOOL_GATES`, so the honest question is what the tool itself declared.
|
|
34
|
+
*
|
|
35
|
+
* `undefined` — a tool built with plain `tool()` rather than `retailTool` — is
|
|
36
|
+
* neither: it has no gate at all, and the sweeps skip it. That is the same
|
|
37
|
+
* answer `gateFor` gives the `tool.called` hook, and "a tool this template did
|
|
38
|
+
* not declare is ignored" below is the assertion for it.
|
|
39
|
+
*/
|
|
40
|
+
const isPublic = (name: string) => gateFor(name)?.includes("identifying") ?? false;
|
|
41
|
+
|
|
42
|
+
/** Registry entries that went through `retailTool`, so the gate has an opinion. */
|
|
43
|
+
const gatedTools = registry.filter(([name]) => gateFor(name) !== undefined);
|
|
44
|
+
|
|
45
|
+
/** The tools this template SHIPS that run before identification. A pin on the
|
|
46
|
+
* shipped set — one of these quietly losing `BEFORE_TRANSFER` is a policy
|
|
47
|
+
* break — and nothing else reads it, so adding a public tool of your own costs
|
|
48
|
+
* no edit here. */
|
|
49
|
+
const SHIPPED_PUBLIC_TOOLS = [
|
|
50
|
+
"find_user_id_by_email",
|
|
51
|
+
"find_user_id_by_name_zip",
|
|
52
|
+
"get_product_details",
|
|
53
|
+
"get_item_details",
|
|
54
|
+
"list_all_product_types",
|
|
55
|
+
"transfer_to_human_agents",
|
|
56
|
+
];
|
|
57
|
+
|
|
39
58
|
// `createToolContext()` rather than a cast: it carries a real slot store (the
|
|
40
59
|
// same storability check and freeze the deployed one applies), and each call is a
|
|
41
60
|
// distinct session, which is what these per-tool cases assume.
|
|
@@ -81,9 +100,19 @@ async function bodyReachableCtx(name: string): Promise<ToolContext> {
|
|
|
81
100
|
return ctx;
|
|
82
101
|
}
|
|
83
102
|
|
|
84
|
-
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
103
|
+
/**
|
|
104
|
+
* Minimal args satisfying each tool's schema. Deliberately plausible-shaped but
|
|
105
|
+
* wrong — these calls are expected to fail; what is asserted is that they still
|
|
106
|
+
* moved the UI.
|
|
107
|
+
*
|
|
108
|
+
* **It is also the list of tools this file sweeps, and the list of tools this
|
|
109
|
+
* template ships.** It used to be a second copy of a seventeen-name array
|
|
110
|
+
* asserted with `toEqual`, so adding one tool of your own failed both — the
|
|
111
|
+
* count and the coverage — before it had run once. One source now: a tool with
|
|
112
|
+
* an entry here is driven through every sweep below, and every name here must
|
|
113
|
+
* still be discovered from `tools/`. Add your tool with its arguments and it
|
|
114
|
+
* joins the sweeps; leave it out and it is simply not covered by them.
|
|
115
|
+
*/
|
|
87
116
|
const SAMPLE_ARGS: Record<string, Record<string, unknown>> = {
|
|
88
117
|
cancel_change: {},
|
|
89
118
|
cancel_pending_order: { order_id: "#W0000000", reason: "no longer needed" },
|
|
@@ -137,56 +166,56 @@ const SAMPLE_ARGS: Record<string, Record<string, unknown>> = {
|
|
|
137
166
|
transfer_to_human_agents: { summary: "test" },
|
|
138
167
|
};
|
|
139
168
|
|
|
169
|
+
/** The tools with sample args, i.e. the ones every sweep below can drive. */
|
|
170
|
+
const sweepable = registry.filter(([name]) => name in SAMPLE_ARGS);
|
|
171
|
+
|
|
140
172
|
describe("tool registry", () => {
|
|
141
|
-
test("
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
"find_user_id_by_email",
|
|
154
|
-
"find_user_id_by_name_zip",
|
|
155
|
-
"get_item_details",
|
|
156
|
-
"get_order_details",
|
|
157
|
-
"get_product_details",
|
|
158
|
-
"get_user_details",
|
|
159
|
-
"list_all_product_types",
|
|
160
|
-
"modify_pending_order_address",
|
|
161
|
-
"modify_pending_order_items",
|
|
162
|
-
"modify_pending_order_payment",
|
|
163
|
-
"modify_user_address",
|
|
164
|
-
"return_delivered_order_items",
|
|
165
|
-
"transfer_to_human_agents",
|
|
166
|
-
].sort(),
|
|
173
|
+
test("discovers every tool this template ships", () => {
|
|
174
|
+
// `arrayContaining` rather than an exact sorted list. Fifteen of these are
|
|
175
|
+
// tau2's retail tool set, which this template used to hold verbatim;
|
|
176
|
+
// `confirm_change` and `cancel_change` are the two it does not have, since
|
|
177
|
+
// tau2's tools apply on their first call and here nothing does (see
|
|
178
|
+
// `pending.ts`). So the list is not a fidelity claim and never was a
|
|
179
|
+
// COUNT — what it is worth asserting is that discovery still finds each
|
|
180
|
+
// one, because a `tools/` glob that resolves nothing looks exactly like a
|
|
181
|
+
// desk with no tools. Adding a tool of your own passes; losing one of these
|
|
182
|
+
// fails, naming it.
|
|
183
|
+
expect(registry.map(([name]) => name)).toEqual(
|
|
184
|
+
expect.arrayContaining(Object.keys(SAMPLE_ARGS)),
|
|
167
185
|
);
|
|
168
186
|
});
|
|
169
187
|
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
test("every
|
|
173
|
-
for (const
|
|
174
|
-
expect.soft(
|
|
188
|
+
// Asserts softly: a batch of renames should list every name that no longer
|
|
189
|
+
// resolves, not just the first.
|
|
190
|
+
test("every name in SAMPLE_ARGS resolves to a real tool", () => {
|
|
191
|
+
for (const name of Object.keys(SAMPLE_ARGS)) {
|
|
192
|
+
expect.soft(retailAgent.tools[name], `SAMPLE_ARGS["${name}"] names no tool`).toBeDefined();
|
|
175
193
|
}
|
|
176
194
|
});
|
|
177
195
|
|
|
178
196
|
test("every tool declares a description the model can act on", () => {
|
|
179
197
|
for (const [name, def] of registry) {
|
|
198
|
+
// Any tool, yours included — the model picks a tool by its description,
|
|
199
|
+
// so an empty one is invisible to it.
|
|
180
200
|
expect.soft(def.description, name).toBeTruthy();
|
|
181
|
-
|
|
201
|
+
}
|
|
202
|
+
for (const name of Object.keys(SAMPLE_ARGS)) {
|
|
203
|
+
// The shipped ones additionally carry enough of the policy to be chosen
|
|
204
|
+
// correctly, which for this desk means more than a label.
|
|
205
|
+
expect.soft(retailAgent.tools[name]?.description.length, name).toBeGreaterThan(40);
|
|
182
206
|
}
|
|
183
207
|
});
|
|
184
208
|
});
|
|
185
209
|
|
|
186
210
|
describe("the UI-update invariant", () => {
|
|
187
|
-
//
|
|
211
|
+
// Over `sweepable` rather than the whole registry: these two REACH a tool's
|
|
212
|
+
// body, so they need arguments its schema accepts, and calling a tool of
|
|
213
|
+
// yours with `{}` would report a crash inside it as a UI regression. Give it
|
|
214
|
+
// a `SAMPLE_ARGS` entry and it is swept like the rest.
|
|
215
|
+
//
|
|
216
|
+
// This is the pair that fails if a future tool is built with tool() instead of
|
|
188
217
|
// retailTool(): it would work, and the sidebar would sit still through it.
|
|
189
|
-
test.each(
|
|
218
|
+
test.each(sweepable)("%s increments callSeq and logs activity", async (name, def) => {
|
|
190
219
|
// In whichever state lets the body run, so the flow gate is not what these
|
|
191
220
|
// calls are testing: the point is that a tool which reaches its BODY moves
|
|
192
221
|
// the sidebar. A refused call never reaches one — the hook below is what
|
|
@@ -200,7 +229,7 @@ describe("the UI-update invariant", () => {
|
|
|
200
229
|
expect(state.activity.at(-1)?.summary).toBeTruthy();
|
|
201
230
|
});
|
|
202
231
|
|
|
203
|
-
test.each(
|
|
232
|
+
test.each(sweepable)("%s logs its own registry key as its name", async (name, def) => {
|
|
204
233
|
// Catches a copy-paste where the retailTool `name` and the registry key
|
|
205
234
|
// disagree — the activity feed would then attribute calls to the wrong tool.
|
|
206
235
|
const ctx = await bodyReachableCtx(name);
|
|
@@ -229,7 +258,7 @@ describe("the blocked-call hook", () => {
|
|
|
229
258
|
ctx,
|
|
230
259
|
);
|
|
231
260
|
|
|
232
|
-
test.each(
|
|
261
|
+
test.each(gatedTools.filter(([name]) => !isPublic(name)))(
|
|
233
262
|
"%s records a blocked line when the model tries it too early",
|
|
234
263
|
(name) => {
|
|
235
264
|
const ctx = makeCtx();
|
|
@@ -245,7 +274,7 @@ describe("the blocked-call hook", () => {
|
|
|
245
274
|
},
|
|
246
275
|
);
|
|
247
276
|
|
|
248
|
-
test.each(
|
|
277
|
+
test.each(gatedTools.filter(([name]) => isPublic(name)))(
|
|
249
278
|
"%s is left to the wrapper, because it is going to run",
|
|
250
279
|
(name) => {
|
|
251
280
|
const ctx = makeCtx();
|
|
@@ -280,7 +309,19 @@ describe("the blocked-call hook", () => {
|
|
|
280
309
|
});
|
|
281
310
|
|
|
282
311
|
describe("the authentication gate", () => {
|
|
283
|
-
test
|
|
312
|
+
test("the tools that run before identification are still the shipped six", () => {
|
|
313
|
+
// The one place `SHIPPED_PUBLIC_TOOLS` is read, and the only assertion in
|
|
314
|
+
// this file about WHICH tools are public. The sweeps below classify by
|
|
315
|
+
// `isPublic`, i.e. by what each tool declared, so a public tool of your own
|
|
316
|
+
// needs no edit here — but one of these six quietly losing
|
|
317
|
+
// `when: BEFORE_TRANSFER` would silently make the desk unreachable before a
|
|
318
|
+
// caller is identified, and nothing else would say so.
|
|
319
|
+
expect(registry.map(([name]) => name).filter(isPublic)).toEqual(
|
|
320
|
+
expect.arrayContaining(SHIPPED_PUBLIC_TOOLS),
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test.each(gatedTools.filter(([name]) => !isPublic(name)))(
|
|
284
325
|
"%s refuses before the caller is identified",
|
|
285
326
|
async (name, def) => {
|
|
286
327
|
const result = await def.execute(SAMPLE_ARGS[name] ?? {}, makeCtx());
|
|
@@ -297,7 +338,7 @@ describe("the authentication gate", () => {
|
|
|
297
338
|
},
|
|
298
339
|
);
|
|
299
340
|
|
|
300
|
-
test.each(
|
|
341
|
+
test.each(sweepable.filter(([name]) => isPublic(name)))(
|
|
301
342
|
"%s does not require authentication",
|
|
302
343
|
async (name, def) => {
|
|
303
344
|
const result = await def.execute(SAMPLE_ARGS[name] ?? {}, makeCtx());
|
|
@@ -331,7 +372,7 @@ describe("agent config", () => {
|
|
|
331
372
|
});
|
|
332
373
|
|
|
333
374
|
describe("the transfer is terminal", () => {
|
|
334
|
-
test.each(
|
|
375
|
+
test.each(gatedTools)("%s refuses once the call is with a human", async (name, def) => {
|
|
335
376
|
const ctx = servingCtx();
|
|
336
377
|
callFlow.send(ctx, { type: "TRANSFERRED" });
|
|
337
378
|
|
|
@@ -2,17 +2,50 @@ import { toAgentConfig } from "@alexkroman1/aai/manifest";
|
|
|
2
2
|
import { describe, expect, test } from "vitest";
|
|
3
3
|
import agentDef from "./agent.ts";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* What a starter's spec may assert.
|
|
7
|
+
*
|
|
8
|
+
* Renaming the agent, giving it a voice, swapping a stage or switching the
|
|
9
|
+
* whole thing to speech-to-speech are the first edits this template invites —
|
|
10
|
+
* and `aai build` runs these tests before it bundles, so an assertion that
|
|
11
|
+
* pins the template's own identity turns the first customization into a build
|
|
12
|
+
* failure in a file the author never wrote. Every test here therefore asserts
|
|
13
|
+
* a property that survives those edits, on the RESOLVED config rather than on
|
|
14
|
+
* the def's empty fields.
|
|
15
|
+
*/
|
|
5
16
|
describe("simple template", () => {
|
|
6
17
|
test("config passes manifest validation", () => {
|
|
7
18
|
// Same conversion `aai build`/`aai deploy` run.
|
|
8
19
|
expect(() => toAgentConfig(agentDef)).not.toThrow();
|
|
9
20
|
});
|
|
10
21
|
|
|
11
|
-
test("exports an agent
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
expect(agentDef.
|
|
16
|
-
expect(agentDef.
|
|
22
|
+
test("exports an agent the platform can name", () => {
|
|
23
|
+
// Not the literal: what has to hold is that there IS a name, and that the
|
|
24
|
+
// conversion carries it through — `AgentName` refuses a blank one, and the
|
|
25
|
+
// studio lists a deployed agent by exactly this string.
|
|
26
|
+
expect(agentDef.name).toBeTruthy();
|
|
27
|
+
expect(toAgentConfig(agentDef).name).toBe(agentDef.name);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("every stage its mode needs is filled, declared or defaulted", () => {
|
|
31
|
+
// The template's point: with no provider fields declared, the default
|
|
32
|
+
// all-AssemblyAI cascaded pipeline is injected at parse time (see
|
|
33
|
+
// `defaultProviders`) — so an agent that declares nothing still runs.
|
|
34
|
+
// Asserted per MODE so it stays true after a swap: declare `stt`/`llm`/`tts`
|
|
35
|
+
// and the rest still default; declare `s2s` and there is no cascade to fill,
|
|
36
|
+
// which is the one thing that must never happen by fallthrough.
|
|
37
|
+
const config = toAgentConfig(agentDef);
|
|
38
|
+
if (config.mode === "s2s") {
|
|
39
|
+
expect(config.s2s?.kind).toBeTruthy();
|
|
40
|
+
expect(config.stt).toBeUndefined();
|
|
41
|
+
expect(config.tts).toBeUndefined();
|
|
42
|
+
} else if (config.mode === "text") {
|
|
43
|
+
expect(config.llm?.kind).toBeTruthy();
|
|
44
|
+
} else {
|
|
45
|
+
expect(config.mode).toBe("pipeline");
|
|
46
|
+
expect(config.stt?.kind).toBeTruthy();
|
|
47
|
+
expect(config.llm?.kind).toBeTruthy();
|
|
48
|
+
expect(config.tts?.kind).toBeTruthy();
|
|
49
|
+
}
|
|
17
50
|
});
|
|
18
51
|
});
|
|
@@ -55,13 +55,22 @@ describe("the declaration", () => {
|
|
|
55
55
|
test("is a workflow app with the one workflow the page starts by name", () => {
|
|
56
56
|
// The page calls `api.start("spokenSummary", …)`, so a rename here is a
|
|
57
57
|
// runtime 400 rather than a compile error. This is what pins it.
|
|
58
|
-
|
|
58
|
+
// `toContain` rather than an exact key list: adding a second workflow is an
|
|
59
|
+
// invited edit and must not redden a test the author did not write. The
|
|
60
|
+
// NAME is still pinned, deliberately — the page starts a run by this
|
|
61
|
+
// string, so renaming the key is a runtime 400 rather than a compile
|
|
62
|
+
// error, and this pin is the only thing that says so. Rename it here and
|
|
63
|
+
// in `client.tsx` together.
|
|
64
|
+
expect(Object.keys(agentDef.workflows ?? {})).toContain("spokenSummary");
|
|
65
|
+
expect(agentDef.workflows?.spokenSummary).toBe(spokenSummary);
|
|
59
66
|
});
|
|
60
67
|
|
|
61
|
-
test("declares
|
|
68
|
+
test("declares the credential its steps read, so a deploy checks for it", () => {
|
|
62
69
|
// A workflow app has no session, so nothing else in its config could name
|
|
63
70
|
// one — and one AssemblyAI key covers transcription, the model and the voice.
|
|
64
|
-
|
|
71
|
+
// `toContain`, so adding a step that reads a second credential and
|
|
72
|
+
// declaring it here does not fail this test.
|
|
73
|
+
expect(agentDef.requiredEnv).toContain("ASSEMBLYAI_API_KEY");
|
|
65
74
|
});
|
|
66
75
|
|
|
67
76
|
test("takes the recording as an UPLOAD, which is what makes the form a file picker", () => {
|
|
@@ -37,14 +37,15 @@
|
|
|
37
37
|
* in React state — so a refresh lost it while the recording carried on being
|
|
38
38
|
* transcribed, summarized and spoken. The bytes were already stored, so the
|
|
39
39
|
* expensive half had happened; what an empty form invites is somebody uploading
|
|
40
|
-
* the recording again and paying for all four legs twice.
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* that did
|
|
40
|
+
* the recording again and paying for all four legs twice. Both halves are the
|
|
41
|
+
* SDK's now and this page writes neither: `useWorkflowSubmit` records the run
|
|
42
|
+
* under a correlation KEY it keeps for this page and asks for it again on the
|
|
43
|
+
* next load, and picking the same file again sends only the windows that did
|
|
44
|
+
* not land.
|
|
44
45
|
*
|
|
45
|
-
* **
|
|
46
|
-
* same lifetime as
|
|
47
|
-
* promise. What
|
|
46
|
+
* **That key is `useRunKey()`'s** — opaque, and in `sessionStorage`, which is
|
|
47
|
+
* the same lifetime as the upload recall, so both halves of a reload make the
|
|
48
|
+
* same promise. What it rules out is a `?key=` parameter, and it is worth being
|
|
48
49
|
* plain about the trade because this template exists to produce something
|
|
49
50
|
* sendable: a summary you can listen to is exactly the sort of thing somebody
|
|
50
51
|
* forwards, and a URL is how people forward things. But there is no per-user
|
|
@@ -57,9 +58,9 @@
|
|
|
57
58
|
* the recording would be worse again: two people summarizing one file would
|
|
58
59
|
* recover each other's runs.
|
|
59
60
|
*
|
|
60
|
-
* A real app with accounts passes the account's own id instead, and
|
|
61
|
-
* summary follows the person to another device — a promise only a login
|
|
62
|
-
* keep.
|
|
61
|
+
* A real app with accounts passes the account's own id as `key` instead, and
|
|
62
|
+
* then a summary follows the person to another device — a promise only a login
|
|
63
|
+
* can keep.
|
|
63
64
|
*/
|
|
64
65
|
|
|
65
66
|
import "@alexkroman1/aai-ui/styles.css";
|
|
@@ -74,7 +75,6 @@ import {
|
|
|
74
75
|
SubmitButton,
|
|
75
76
|
UploadProgressBar,
|
|
76
77
|
useDownloadUrl,
|
|
77
|
-
useRunKey,
|
|
78
78
|
useWorkflowSubmit,
|
|
79
79
|
WorkflowFields,
|
|
80
80
|
WorkflowProgress,
|
|
@@ -128,20 +128,16 @@ function captionsUrl(text: string, durationMs: number): string {
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
export function App() {
|
|
131
|
-
// This tab's handle on its own summaries, in the store the upload recall uses
|
|
132
|
-
// — see the module doc for why a `?key=` is the wrong trade here.
|
|
133
|
-
const key = useRunKey();
|
|
134
131
|
// Did THIS load start the run? A reload cannot have, and that is the only way
|
|
135
132
|
// the page can tell "working on what you just sent" from "picking up where
|
|
136
133
|
// you left off" — the hook reports the run, not who asked for it.
|
|
137
134
|
const [startedHere, setStartedHere] = useState(false);
|
|
138
135
|
// The generic is what makes `run.status === "completed"` narrow to a TYPED
|
|
139
|
-
// `run.output` instead of `unknown`.
|
|
140
|
-
//
|
|
141
|
-
// `recover` the key is only ever written.
|
|
136
|
+
// `run.output` instead of `unknown`. The reload is the hook's own doing — see
|
|
137
|
+
// the module doc for why the key it mints is the right one for this page.
|
|
142
138
|
const { submitForm, run, pending, upload, pauseUpload, resumeUpload, error } = useWorkflowSubmit<
|
|
143
139
|
typeof spokenSummary
|
|
144
|
-
>(WORKFLOW, { api
|
|
140
|
+
>(WORKFLOW, { api });
|
|
145
141
|
const output = run?.status === "completed" ? run.output : undefined;
|
|
146
142
|
// `useDownloadUrl` is the SDK's: the byte route takes the agent's bearer, so the
|
|
147
143
|
// bytes have to be FETCHED and handed to the element as an object URL — and the
|