@alexkroman1/aai-cli 8.2.1 → 9.0.1
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/_dev-env.d.ts +15 -2
- package/dist/{_dev-server-Dt3TCjhj.mjs → _dev-server-BAR8Ya43.mjs} +182 -55
- package/dist/_dev-server.d.ts +5 -30
- package/dist/_dev-typecheck.d.ts +38 -0
- package/dist/_dev-vite-config.d.ts +74 -0
- package/dist/_e2e-test-utils.d.ts +33 -1
- package/dist/_init-BNotRgV1.mjs +163 -0
- package/dist/_resource-commands.d.ts +0 -1
- package/dist/{build-BBxf8vom.mjs → build-DvDdSwAS.mjs} +1 -1
- package/dist/cli.mjs +22 -93
- package/dist/{delete-CPf347lo.mjs → delete-8pjH8n8n.mjs} +2 -1
- package/dist/{deploy-BqV6ShIe.mjs → deploy-CjU3e-3P.mjs} +3 -3
- package/dist/{dev-QuuOOUtv.mjs → dev-dFzdvwa7.mjs} +3 -2
- package/dist/dev.d.ts +2 -0
- package/dist/{eval-DfQ8cm5p.mjs → eval-DwNwdqmS.mjs} +1 -1
- package/dist/{init-Cp5GZg0B.mjs → init-DK7GpIF9.mjs} +7 -6
- package/dist/scaffold/.env.example +7 -4
- package/dist/scaffold/CLAUDE.md +152 -97
- package/dist/scaffold/global.d.ts +12 -0
- package/dist/scaffold/package.json +4 -4
- package/dist/scaffold/server.mjs +49 -2
- package/dist/scaffold/tsconfig.json +1 -0
- package/dist/scaffold/vitest.config.ts +24 -0
- package/dist/{studio-DUfCH9I8.mjs → studio-D3-HWIfk.mjs} +3 -3
- package/dist/templates/briefing-desk/agent.test.ts +3 -15
- package/dist/templates/briefing-desk/shared.ts +3 -3
- package/dist/templates/call-audit/agent.eval.test.ts +8 -1
- package/dist/templates/call-audit/agent.test.ts +0 -1
- package/dist/templates/call-audit/agent.ts +4 -3
- package/dist/templates/call-audit/client.tsx +4 -7
- package/dist/templates/call-audit/workflows/audit.ts +10 -1
- package/dist/templates/code-interpreter/agent.eval.test.ts +14 -43
- package/dist/templates/dispatch-center/agent.eval.test.ts +16 -26
- package/dist/templates/embedded-assets/agent.eval.test.ts +6 -16
- package/dist/templates/embedded-assets/agent.test.ts +3 -16
- package/dist/templates/health-assistant/agent.eval.test.ts +5 -15
- package/dist/templates/health-assistant/agent.test.ts +3 -14
- package/dist/templates/infocom-adventure/agent.eval.test.ts +6 -16
- package/dist/templates/infocom-adventure/agent.test.ts +3 -17
- package/dist/templates/link-digest/agent.test.ts +0 -14
- package/dist/templates/link-digest/agent.ts +9 -5
- package/dist/templates/link-digest/client.tsx +8 -6
- package/dist/templates/math-buddy/agent.eval.test.ts +11 -40
- package/dist/templates/night-owl/agent.eval.test.ts +15 -44
- package/dist/templates/night-owl/agent.test.ts +2 -15
- package/dist/templates/personal-finance/agent.eval.test.ts +10 -39
- package/dist/templates/pipeline-simple/agent.test.ts +73 -0
- package/dist/templates/pizza-ordering/agent.eval.test.ts +2 -17
- package/dist/templates/pizza-ordering/agent.test.ts +2 -17
- package/dist/templates/plan-and-execute/agent.eval.test.ts +2 -12
- package/dist/templates/plan-and-execute/agent.test.ts +3 -23
- package/dist/templates/plan-and-execute/shared.ts +1 -1
- package/dist/templates/podcast-digest/agent.test.ts +0 -1
- package/dist/templates/podcast-digest/client.tsx +3 -6
- package/dist/templates/podcast-digest/workflows/digest.ts +16 -8
- package/dist/templates/recap-workflow/agent.eval.test.ts +7 -14
- package/dist/templates/recap-workflow/agent.test.ts +3 -18
- package/dist/templates/recap-workflow/agent.ts +7 -5
- package/dist/templates/redline/agent.test.ts +0 -13
- package/dist/templates/redline/agent.ts +3 -2
- package/dist/templates/redline/client.tsx +9 -4
- package/dist/templates/research-workflow/agent.eval.test.ts +7 -14
- package/dist/templates/research-workflow/agent.test.ts +3 -15
- package/dist/templates/research-workflow/agent.ts +7 -5
- package/dist/templates/research-workflow/workflows/research.ts +8 -2
- package/dist/templates/retail/agent.eval.test.ts +10 -25
- package/dist/templates/retail/registry.test.ts +16 -13
- package/dist/templates/solo-rpg/agent.eval.test.ts +5 -15
- package/dist/templates/solo-rpg/agent.test.ts +17 -124
- package/dist/templates/solo-rpg/shared.ts +13 -61
- package/dist/templates/spoken-summary/agent.test.ts +0 -1
- package/dist/templates/spoken-summary/agent.ts +5 -4
- package/dist/templates/spoken-summary/client.tsx +4 -7
- package/dist/templates/support-line/agent.eval.test.ts +2 -12
- package/dist/templates/support-line/agent.test.ts +3 -22
- package/dist/templates/transcription-workflow/agent.ts +4 -4
- package/dist/templates/transcription-workflow/client.tsx +5 -5
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +10 -1
- package/dist/templates/travel-concierge/agent.eval.test.ts +5 -15
- package/dist/templates/travel-concierge/agent.test.ts +3 -23
- package/dist/templates/web-researcher/agent.eval.test.ts +2 -5
- package/dist/test-CvwgeVSQ.mjs +181 -0
- package/dist/test.d.ts +33 -2
- package/package.json +4 -4
- package/dist/_init-BhEfQ6Yi.mjs +0 -93
- package/dist/storage-Bfs7QQWq.mjs +0 -66
- package/dist/storage.d.ts +0 -22
- package/dist/templates/solo-rpg/tools/load_game.ts +0 -37
- package/dist/templates/solo-rpg/tools/save_game.ts +0 -32
- package/dist/test-BvyT6sAc.mjs +0 -104
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
|
|
29
29
|
import "@alexkroman1/aai-ui/styles.css";
|
|
30
30
|
import { plural } from "@alexkroman1/aai/utils";
|
|
31
|
-
import type { WorkflowOutputOf } from "@alexkroman1/aai/workflow-api";
|
|
31
|
+
import type { WorkflowInputOf, WorkflowOutputOf } from "@alexkroman1/aai/workflow-api";
|
|
32
32
|
import {
|
|
33
33
|
Form,
|
|
34
34
|
type FormValues,
|
|
@@ -62,10 +62,15 @@ const WORKFLOW = "redline";
|
|
|
62
62
|
* thing that otherwise gets half-done in three places. Blank lines go, so a
|
|
63
63
|
* trailing newline is not a requirement to cover "".
|
|
64
64
|
*/
|
|
65
|
-
export function toInput(values: FormValues):
|
|
65
|
+
export function toInput(values: FormValues): WorkflowInputOf<typeof redline> {
|
|
66
66
|
const raw = typeof values.mustCover === "string" ? values.mustCover : "";
|
|
67
|
+
// The scalars ride through as the form collected them — strings from the DOM,
|
|
68
|
+
// which the WORKFLOW's schema coerces and validates server-side. Only
|
|
69
|
+
// `mustCover` is reshaped here, because no control renders a `string[]`.
|
|
70
|
+
// The assertion is on the scalars alone and is what `submitForm` exists to
|
|
71
|
+
// avoid needing anywhere a page is not doing this reshaping deliberately.
|
|
67
72
|
return {
|
|
68
|
-
...values,
|
|
73
|
+
...(values as Omit<WorkflowInputOf<typeof redline>, "mustCover">),
|
|
69
74
|
mustCover: raw
|
|
70
75
|
.split("\n")
|
|
71
76
|
.map((line) => line.trim())
|
|
@@ -74,7 +79,7 @@ export function toInput(values: FormValues): FormValues {
|
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
function RedlineDesk() {
|
|
77
|
-
const { submit, run, pending, error, reset } = useWorkflowSubmit<
|
|
82
|
+
const { submit, run, pending, error, reset } = useWorkflowSubmit<typeof redline>(WORKFLOW);
|
|
78
83
|
|
|
79
84
|
return (
|
|
80
85
|
<main className="mx-auto flex max-w-2xl flex-col gap-8 p-8">
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
/// <reference types="vite/client" />
|
|
2
|
-
|
|
3
1
|
// An EVAL: does this desk actually behave? Run it with `aai eval`.
|
|
4
2
|
//
|
|
5
3
|
// `agent.test.ts` drives the four tools against a STUBBED `ctx.workflows` and
|
|
@@ -29,14 +27,6 @@
|
|
|
29
27
|
//
|
|
30
28
|
// And what no eval here can see at all: anything below the audio boundary —
|
|
31
29
|
// endpointing, barge-in, whether two sentences merged into one turn.
|
|
32
|
-
import { withDiscoveredTools } from "@alexkroman1/aai/testing";
|
|
33
|
-
import { installStubStepFetch } from "@alexkroman1/aai/testing/vitest";
|
|
34
|
-
import type { EvalToolCall, EvalWorkflows } from "@alexkroman1/aai-runtime/eval";
|
|
35
|
-
import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
36
|
-
import { expect } from "vitest";
|
|
37
|
-
import { z } from "zod";
|
|
38
|
-
import authoredAgent from "./agent.ts";
|
|
39
|
-
import { research } from "./shared.ts";
|
|
40
30
|
|
|
41
31
|
/**
|
|
42
32
|
* The def a DEPLOYED agent runs: authored, plus what `tools/` declares.
|
|
@@ -50,10 +40,13 @@ import { research } from "./shared.ts";
|
|
|
50
40
|
* The glob is written here rather than reached for from a shared helper because
|
|
51
41
|
* this file SHIPS — see `agent.test.ts`.
|
|
52
42
|
*/
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
43
|
+
import agentDef from "virtual:aai/agent";
|
|
44
|
+
import { installStubStepFetch } from "@alexkroman1/aai/testing/vitest";
|
|
45
|
+
import type { EvalToolCall, EvalWorkflows } from "@alexkroman1/aai-runtime/eval";
|
|
46
|
+
import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
47
|
+
import { expect } from "vitest";
|
|
48
|
+
import { z } from "zod";
|
|
49
|
+
import { research } from "./shared.ts";
|
|
57
50
|
|
|
58
51
|
/**
|
|
59
52
|
* The key the run's steps read with `requireStepEnv`.
|
|
@@ -28,14 +28,12 @@ import {
|
|
|
28
28
|
type StubGatewayCall,
|
|
29
29
|
schemaInputIssues,
|
|
30
30
|
toolRunner,
|
|
31
|
-
withDiscoveredTools,
|
|
32
31
|
} from "@alexkroman1/aai/testing";
|
|
33
32
|
import { mockWorkflows, installStubGateway as stubGateway } from "@alexkroman1/aai/testing/vitest";
|
|
34
33
|
import { visitWebpage, webSearch } from "@alexkroman1/aai/tools";
|
|
35
34
|
import type { WorkflowRunSnapshot } from "@alexkroman1/aai/workflow-api";
|
|
36
35
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
37
36
|
import { FatalError, RetryableError } from "workflow";
|
|
38
|
-
import authoredAgent from "./agent.ts";
|
|
39
37
|
import { research } from "./shared.ts";
|
|
40
38
|
import {
|
|
41
39
|
countSources,
|
|
@@ -63,18 +61,8 @@ vi.mock("@alexkroman1/aai/tools", () => ({
|
|
|
63
61
|
visitWebpage: vi.fn(async () => ({ content: "The page body." })),
|
|
64
62
|
}));
|
|
65
63
|
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
*
|
|
69
|
-
* The glob is written HERE rather than reached for from a shared helper because
|
|
70
|
-
* this file SHIPS: it is what a scaffolded project runs, so it may not import
|
|
71
|
-
* anything outside its own template, and `import.meta.glob` is expanded against
|
|
72
|
-
* the file containing it either way. This is the pattern a user writes.
|
|
73
|
-
*/
|
|
74
|
-
const agentDef = withDiscoveredTools(
|
|
75
|
-
authoredAgent,
|
|
76
|
-
import.meta.glob("./tools/*.ts", { eager: true }),
|
|
77
|
-
);
|
|
64
|
+
/** The def a DEPLOYED agent runs: authored, plus what `tools/` declares. */
|
|
65
|
+
import agentDef from "virtual:aai/agent";
|
|
78
66
|
|
|
79
67
|
/**
|
|
80
68
|
* Every tool here is driven through the agent's own table, by the name the model
|
|
@@ -404,7 +392,7 @@ describe("the steps that research", () => {
|
|
|
404
392
|
|
|
405
393
|
const note = await investigate(brief, "Tool use");
|
|
406
394
|
|
|
407
|
-
expect(webSearch).toHaveBeenCalledWith({ query: "otter tool use",
|
|
395
|
+
expect(webSearch).toHaveBeenCalledWith({ query: "otter tool use", maxResults: 5 });
|
|
408
396
|
expect(visitWebpage).toHaveBeenCalledWith("https://otters.example/tools");
|
|
409
397
|
expect(note.findings).toContain("crack shellfish");
|
|
410
398
|
expect(note.sources).toEqual([{ title: "Otters", url: "https://otters.example/tools" }]);
|
|
@@ -69,11 +69,13 @@
|
|
|
69
69
|
* thing that changes under `aai dev` (the key has to be in `.env`, not just your
|
|
70
70
|
* shell).
|
|
71
71
|
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
72
|
+
* Runs are DURABLE on the platform with nothing to configure — they live on the
|
|
73
|
+
* platform's own database and survive a restart, a redeploy and an idle sandbox.
|
|
74
|
+
*
|
|
75
|
+
* A `DATABASE_URL` you supply (a secret when deployed, `.env` under `aai dev`)
|
|
76
|
+
* buys the key index, which is what lets `find()` resolve a run by key across a
|
|
77
|
+
* restart. Under `aai dev` with none, the runs go with the process too —
|
|
78
|
+
* everything below still works, which is what lets you try it first.
|
|
77
79
|
*/
|
|
78
80
|
|
|
79
81
|
import { agent } from "@alexkroman1/aai";
|
|
@@ -65,7 +65,13 @@ import {
|
|
|
65
65
|
RESEARCH_SYSTEM,
|
|
66
66
|
} from "./prompts.ts";
|
|
67
67
|
|
|
68
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* Angles investigated at once. The far side of every one is a rate limit.
|
|
70
|
+
*
|
|
71
|
+
* Inside the workflow world's default worker concurrency (three on the
|
|
72
|
+
* `DATABASE_URL` path), so this width is what really runs — see "The WINDOW is
|
|
73
|
+
* not the concurrency" in `mapConcurrent`.
|
|
74
|
+
*/
|
|
69
75
|
const ANGLE_CONCURRENCY = 2;
|
|
70
76
|
|
|
71
77
|
/**
|
|
@@ -424,7 +430,7 @@ async function search(query: string): Promise<{ summary: string; sources: Source
|
|
|
424
430
|
try {
|
|
425
431
|
const results = await webSearch<{ results?: { title?: string; url?: string }[] }>({
|
|
426
432
|
query,
|
|
427
|
-
|
|
433
|
+
maxResults: SEARCH_RESULTS,
|
|
428
434
|
});
|
|
429
435
|
// The `catch` below was written for exactly this and could not reach it:
|
|
430
436
|
// `webSearch` ANSWERS with `{ error }` rather than throwing, so a refused
|
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The def a DEPLOYED agent runs: authored, plus what `tools/` declares, plus
|
|
3
|
+
* what `system-prompt.md` says.
|
|
4
|
+
*
|
|
5
|
+
* Driving the raw default export instead would measure a seventeen-tool desk
|
|
6
|
+
* with no tools and the FRAMEWORK DEFAULT prompt — and for this template that
|
|
7
|
+
* is the whole subject: the authenticate-first discipline, the three-step
|
|
8
|
+
* readback and the one-customer-per-call rule all live in that file.
|
|
9
|
+
*/
|
|
10
|
+
import retailAgent from "virtual:aai/agent";
|
|
4
11
|
import type { SessionEvent } from "@alexkroman1/aai/protocol";
|
|
5
|
-
import { withDiscoveredTools } from "@alexkroman1/aai/testing";
|
|
6
12
|
// An EVAL: does this desk actually behave? Run it with `aai eval`.
|
|
7
13
|
//
|
|
8
14
|
// `agent.test.ts` drives each tool directly and asserts about its result;
|
|
@@ -29,27 +35,6 @@ import { type EvalSession, type EvalTurn, lastStateIn } from "@alexkroman1/aai-r
|
|
|
29
35
|
import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
30
36
|
import { expect } from "vitest";
|
|
31
37
|
import { z } from "zod";
|
|
32
|
-
import authoredAgent from "./agent.ts";
|
|
33
|
-
import systemPrompt from "./system-prompt.md?raw";
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* The def a DEPLOYED agent runs: authored, plus what `tools/` declares, plus
|
|
37
|
-
* what `system-prompt.md` says.
|
|
38
|
-
*
|
|
39
|
-
* BOTH wrappers are load-bearing and neither is applied by `agent()` — they are
|
|
40
|
-
* applied by the BUILD (`aai build` enumerates `tools/` and resolves the prompt
|
|
41
|
-
* file), so an eval driving the raw default export would measure a
|
|
42
|
-
* seventeen-tool desk with no tools and the FRAMEWORK DEFAULT prompt. For this
|
|
43
|
-
* template that is the whole subject: the authenticate-first discipline, the
|
|
44
|
-
* three-step readback and the one-customer-per-call rule are all in that file.
|
|
45
|
-
*
|
|
46
|
-
* The glob is written here rather than reached for from a shared helper because
|
|
47
|
-
* this file SHIPS — see `registry.test.ts`.
|
|
48
|
-
*/
|
|
49
|
-
const retailAgent = withSystemPrompt(
|
|
50
|
-
withDiscoveredTools(authoredAgent, import.meta.glob("./tools/*.ts", { eager: true })),
|
|
51
|
-
systemPrompt,
|
|
52
|
-
);
|
|
53
38
|
|
|
54
39
|
/** Harper Brown: exactly ONE pending order, which is what makes "cancel my
|
|
55
40
|
* pending order" unambiguous — `resolveOrder` refuses a reference that matches
|
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
/// <reference types="vite/client" />
|
|
2
|
-
|
|
3
1
|
import { isToolFailure, type ToolContext } from "@alexkroman1/aai";
|
|
4
|
-
import { createToolContext,
|
|
2
|
+
import { createToolContext, deployedAgent } from "@alexkroman1/aai/testing";
|
|
5
3
|
import { describe, expect, test } from "vitest";
|
|
6
|
-
import authoredAgent from "./agent.ts";
|
|
7
4
|
import { callFlow, retailSlot } from "./store.ts";
|
|
8
5
|
|
|
9
6
|
/** Tools that legitimately run before the caller is identified — the six
|
|
@@ -20,17 +17,23 @@ const PUBLIC_TOOLS = new Set([
|
|
|
20
17
|
]);
|
|
21
18
|
|
|
22
19
|
/**
|
|
23
|
-
* The def a DEPLOYED agent runs
|
|
20
|
+
* The def a DEPLOYED agent runs, lowered BY HAND — the one place in the
|
|
21
|
+
* templates that still does.
|
|
24
22
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
23
|
+
* `virtual:aai/agent` is what every other spec imports and what a user should
|
|
24
|
+
* reach for. This file is the exception on purpose: its whole subject is the
|
|
25
|
+
* tool REGISTRY, so doing the discovery explicitly is the thing under test
|
|
26
|
+
* rather than setup around it. It is also the worked example for a project
|
|
27
|
+
* whose runner is not vitest, and so cannot register the plugin.
|
|
29
28
|
*/
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
import authoredAgent from "./agent.ts";
|
|
30
|
+
import systemPrompt from "./system-prompt.md?raw";
|
|
31
|
+
|
|
32
|
+
const retailAgent = deployedAgent(authoredAgent, {
|
|
33
|
+
tools: import.meta.glob("./tools/*.ts", { eager: true }),
|
|
34
|
+
systemPrompt,
|
|
35
|
+
});
|
|
36
|
+
|
|
34
37
|
const registry = Object.entries(retailAgent.tools);
|
|
35
38
|
|
|
36
39
|
// `createToolContext()` rather than a cast: it carries a real slot store (the
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
/// <reference types="vite/client" />
|
|
2
|
-
|
|
3
1
|
// An EVAL: does the story machine actually hold? Run it with `aai eval`.
|
|
4
2
|
//
|
|
5
3
|
// `agent.test.ts` drives each tool directly against a context it made itself,
|
|
@@ -25,15 +23,6 @@
|
|
|
25
23
|
// What no eval here can see: anything below the audio boundary — endpointing,
|
|
26
24
|
// barge-in, a sentence split across two turns. Those need real paced audio.
|
|
27
25
|
|
|
28
|
-
import { withSystemPrompt } from "@alexkroman1/aai/manifest";
|
|
29
|
-
import { withDiscoveredTools } from "@alexkroman1/aai/testing";
|
|
30
|
-
import { type EvalTurn, toolResultIn } from "@alexkroman1/aai-runtime/eval";
|
|
31
|
-
import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
32
|
-
import { expect } from "vitest";
|
|
33
|
-
import { z } from "zod";
|
|
34
|
-
import authoredAgent from "./agent.ts";
|
|
35
|
-
import systemPrompt from "./system-prompt.md?raw";
|
|
36
|
-
|
|
37
26
|
/**
|
|
38
27
|
* The def a DEPLOYED agent runs, assembled the way the build assembles it: the
|
|
39
28
|
* authored export, plus what `tools/` declares, plus `system-prompt.md`.
|
|
@@ -43,10 +32,11 @@ import systemPrompt from "./system-prompt.md?raw";
|
|
|
43
32
|
* eval run against the framework default prompt would measure an agent nobody
|
|
44
33
|
* deployed.
|
|
45
34
|
*/
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
35
|
+
import agentDef from "virtual:aai/agent";
|
|
36
|
+
import { type EvalTurn, toolResultIn } from "@alexkroman1/aai-runtime/eval";
|
|
37
|
+
import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
38
|
+
import { expect } from "vitest";
|
|
39
|
+
import { z } from "zod";
|
|
50
40
|
|
|
51
41
|
/**
|
|
52
42
|
* What each tool this file drives answers, off the wire.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ToolContext, ToolDef, ToolInputSchema } from "@alexkroman1/aai";
|
|
2
2
|
import { isToolFailure } from "@alexkroman1/aai";
|
|
3
3
|
import { createToolContext, ok } from "@alexkroman1/aai/testing";
|
|
4
4
|
import { describe, expect, test, vi } from "vitest";
|
|
@@ -18,44 +18,17 @@ import {
|
|
|
18
18
|
import actionRoll from "./tools/action_roll.ts";
|
|
19
19
|
import burnMomentum from "./tools/burn_momentum.ts";
|
|
20
20
|
import checkState from "./tools/check_state.ts";
|
|
21
|
-
import loadGame from "./tools/load_game.ts";
|
|
22
21
|
import oracle from "./tools/oracle.ts";
|
|
23
|
-
import saveGame from "./tools/save_game.ts";
|
|
24
22
|
import setupCharacter from "./tools/setup_character.ts";
|
|
25
23
|
import updateState from "./tools/update_state.ts";
|
|
26
24
|
|
|
27
25
|
// ── Test doubles ─────────────────────────────────────────────────────────────
|
|
28
26
|
|
|
29
|
-
/**
|
|
30
|
-
* Map-backed fake of the app database, implementing exactly the three SQL
|
|
31
|
-
* statements the shared save-slot helpers emit (create table / select /
|
|
32
|
-
* upsert). Values are stored parsed, the way a postgres driver returns jsonb.
|
|
33
|
-
*/
|
|
34
|
-
function makeDb(): { db: Db; rows: Map<string, unknown> } {
|
|
35
|
-
const rows = new Map<string, unknown>();
|
|
36
|
-
const db: Db = {
|
|
37
|
-
async query<T>(sql: string, params: unknown[] = []): Promise<T[]> {
|
|
38
|
-
if (sql.startsWith("create table if not exists app_state")) return [];
|
|
39
|
-
if (sql.startsWith("select value from app_state")) {
|
|
40
|
-
const key = params[0] as string;
|
|
41
|
-
return rows.has(key) ? ([{ value: structuredClone(rows.get(key)) }] as T[]) : [];
|
|
42
|
-
}
|
|
43
|
-
if (sql.startsWith("insert into app_state")) {
|
|
44
|
-
const [key, json] = params as [string, string];
|
|
45
|
-
rows.set(key, JSON.parse(json)); // $2::jsonb — parsed like postgres would
|
|
46
|
-
return [];
|
|
47
|
-
}
|
|
48
|
-
throw new Error(`unexpected SQL in test: ${sql}`);
|
|
49
|
-
},
|
|
50
|
-
};
|
|
51
|
-
return { db, rows };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
27
|
/** `send` is a spy rather than the recorder `createToolContext` installs,
|
|
55
28
|
* because this suite asserts call counts on it. Each call gets its own slot
|
|
56
29
|
* store, which is what makes two contexts two games. */
|
|
57
|
-
function makeCtx(
|
|
58
|
-
return createToolContext({
|
|
30
|
+
function makeCtx(): ToolContext {
|
|
31
|
+
return createToolContext({ send: vi.fn() });
|
|
59
32
|
}
|
|
60
33
|
|
|
61
34
|
const SETUP_ARGS = {
|
|
@@ -588,65 +561,13 @@ describe("update_state", () => {
|
|
|
588
561
|
expect(
|
|
589
562
|
params.parse({ addClockSegments: 6, updateNpcBond: 4, timeOfDay: "night" }),
|
|
590
563
|
).toBeTruthy();
|
|
591
|
-
|
|
592
|
-
const slotParams = saveGame.inputSchema!;
|
|
593
|
-
expect(() => slotParams.parse({ slot: "../../etc" })).toThrow();
|
|
594
|
-
expect(() => slotParams.parse({ slot: "a".repeat(40) })).toThrow();
|
|
595
|
-
expect(slotParams.parse({ slot: "chapter-2" })).toBeTruthy();
|
|
596
564
|
});
|
|
597
565
|
});
|
|
598
566
|
|
|
599
|
-
//
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
const { db, rows } = makeDb();
|
|
604
|
-
|
|
605
|
-
// Session A plays and saves.
|
|
606
|
-
const sessionA = makeCtx(db);
|
|
607
|
-
const played = playingState();
|
|
608
|
-
played.playerName = "Kael";
|
|
609
|
-
played.sceneCount = 7;
|
|
610
|
-
seedPlaying(sessionA, played);
|
|
611
|
-
const saved = ok<{ saved: boolean; slot: string }>(
|
|
612
|
-
await saveGame.execute({ slot: "chapter-2" }, sessionA),
|
|
613
|
-
);
|
|
614
|
-
expect(saved.saved).toBe(true);
|
|
615
|
-
expect(saved.slot).toBe("chapter-2");
|
|
616
|
-
expect(rows.get("save:chapter-2")).toMatchObject({ playerName: "Kael", sceneCount: 7 });
|
|
617
|
-
|
|
618
|
-
// Session B (a fresh game slot, the same app db) resumes it.
|
|
619
|
-
const sessionB = makeCtx(db);
|
|
620
|
-
// `load_game` is a plain `tool()`, so its result is the union its body
|
|
621
|
-
// writes rather than a position envelope — the guard is what picks the
|
|
622
|
-
// arm, where the old `as Record<string, unknown>` picked neither.
|
|
623
|
-
const loaded = await loadGame.execute({ slot: "chapter-2" }, sessionB);
|
|
624
|
-
if (isToolFailure(loaded)) throw new Error(`load refused: ${loaded.error}`);
|
|
625
|
-
expect(loaded.loaded).toBe(true);
|
|
626
|
-
expect(loaded.playerName).toBe("Kael");
|
|
627
|
-
expect(loaded.sceneCount).toBe(7);
|
|
628
|
-
expect(gameSlot.get(sessionB).playerName).toBe("Kael");
|
|
629
|
-
});
|
|
630
|
-
|
|
631
|
-
test("loading a missing slot reports an error instead of resetting the game", async () => {
|
|
632
|
-
const ctx = makeCtx();
|
|
633
|
-
const result = await loadGame.execute({ slot: "nope" }, ctx);
|
|
634
|
-
expect(isToolFailure(result) && result.error).toMatch(/No save found/);
|
|
635
|
-
});
|
|
636
|
-
|
|
637
|
-
test("saving twice to one slot upserts — the newer save wins", async () => {
|
|
638
|
-
const { db, rows } = makeDb();
|
|
639
|
-
const ctx = makeCtx(db);
|
|
640
|
-
seedPlaying(ctx);
|
|
641
|
-
await saveGame.execute({}, ctx); // autosave
|
|
642
|
-
gameSlot.update(ctx, (game) => {
|
|
643
|
-
game.sceneCount = 9;
|
|
644
|
-
});
|
|
645
|
-
await saveGame.execute({}, ctx);
|
|
646
|
-
expect(rows.size).toBe(1);
|
|
647
|
-
expect(rows.get("save:autosave")).toMatchObject({ sceneCount: 9 });
|
|
648
|
-
});
|
|
649
|
-
});
|
|
567
|
+
// A `save_game / load_game` suite stood here, driving cross-session persistence
|
|
568
|
+
// through a map-backed fake of `ctx.db`. Both tools are gone: `ctx.db` is gone,
|
|
569
|
+
// and a shipped template cannot reach a database (see `shared.ts`). This
|
|
570
|
+
// adventure is single-session now.
|
|
650
571
|
|
|
651
572
|
// ── the story flow ───────────────────────────────────────────────────────────
|
|
652
573
|
|
|
@@ -655,10 +576,11 @@ describe("the story flow", () => {
|
|
|
655
576
|
const ctx = makeCtx();
|
|
656
577
|
expect(storyFlow.position(ctx).state).toBe("awaitingSetup");
|
|
657
578
|
|
|
658
|
-
// All
|
|
659
|
-
//
|
|
660
|
-
//
|
|
661
|
-
// `load_game` would later restore over a real one
|
|
579
|
+
// All of these used to RUN before a character existed: `action_roll` rolled
|
|
580
|
+
// 2d6 against the stats of nobody and applied consequences to a game that was
|
|
581
|
+
// not there. (`save_game` was in this list too, writing an empty campaign to a
|
|
582
|
+
// slot `load_game` would later restore over a real one — both are gone with
|
|
583
|
+
// `ctx.db`.)
|
|
662
584
|
for (const call of [
|
|
663
585
|
actionRoll.execute(
|
|
664
586
|
{ move: "clash", stat: "iron", position: "risky", effect: "standard", purpose: "swing" },
|
|
@@ -666,7 +588,6 @@ describe("the story flow", () => {
|
|
|
666
588
|
),
|
|
667
589
|
updateState.execute({ location: "Nowhere" }, ctx),
|
|
668
590
|
callNoArgs(burnMomentum, ctx),
|
|
669
|
-
saveGame.execute({}, ctx),
|
|
670
591
|
]) {
|
|
671
592
|
const refusal = await call;
|
|
672
593
|
expect(isToolFailure(refusal)).toBe(true);
|
|
@@ -740,37 +661,9 @@ describe("the story flow", () => {
|
|
|
740
661
|
expect(restarted.state).toBe("playing.awaitingRoll");
|
|
741
662
|
});
|
|
742
663
|
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
const sessionB = makeCtx(db);
|
|
750
|
-
expect(storyFlow.position(sessionB).state).toBe("awaitingSetup");
|
|
751
|
-
const loaded = await loadGame.execute({ slot: "resume" }, sessionB);
|
|
752
|
-
if (isToolFailure(loaded)) throw new Error(`load refused: ${loaded.error}`);
|
|
753
|
-
expect(loaded.state).toBe("playing.awaitingRoll");
|
|
754
|
-
|
|
755
|
-
// And the play tools are available in the resumed session.
|
|
756
|
-
ok(await updateState.execute({ location: "Back at the Docks" }, sessionB));
|
|
757
|
-
});
|
|
758
|
-
|
|
759
|
-
test("a save whose game was over resumes as over", async () => {
|
|
760
|
-
const { db } = makeDb();
|
|
761
|
-
const sessionA = makeCtx(db);
|
|
762
|
-
const dead = playingState();
|
|
763
|
-
dead.health = 0;
|
|
764
|
-
dead.spirit = 0;
|
|
765
|
-
dead.gameOver = true;
|
|
766
|
-
dead.crisisMode = true;
|
|
767
|
-
seedPlaying(sessionA, dead);
|
|
768
|
-
ok(await saveGame.execute({ slot: "ended" }, sessionA));
|
|
769
|
-
|
|
770
|
-
const sessionB = makeCtx(db);
|
|
771
|
-
const loaded = await loadGame.execute({ slot: "ended" }, sessionB);
|
|
772
|
-
if (isToolFailure(loaded)) throw new Error(`load refused: ${loaded.error}`);
|
|
773
|
-
expect(loaded.state).toBe("gameOver");
|
|
774
|
-
expect(storyFlow.position(sessionB).done).toBe(true);
|
|
775
|
-
});
|
|
664
|
+
// Two resume tests stood here — a saved game reopening in play, and one saved
|
|
665
|
+
// after the ending reopening as over. Both drove `save_game`/`load_game`, which
|
|
666
|
+
// are gone with `ctx.db`. What they proved about the FLOW (a resumed position is
|
|
667
|
+
// restored rather than recomputed) has no path left to exercise it: a session
|
|
668
|
+
// is the whole life of a game now.
|
|
776
669
|
});
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { type DeepReadonly, dialog, sessionSlot
|
|
2
|
-
import { z } from "zod";
|
|
1
|
+
import { type DeepReadonly, dialog, sessionSlot } from "@alexkroman1/aai";
|
|
3
2
|
|
|
4
3
|
// ── Tuning Constants ─────────────────────────────────────────────────────────
|
|
5
4
|
export const MAX_SESSION_LOG = 50;
|
|
@@ -390,7 +389,7 @@ export const gameProjection = gameSlot.projection((game) => game);
|
|
|
390
389
|
* `storyFlow.reset` — and it is what lets `gameOver` be a genuinely `final`
|
|
391
390
|
* state, which is the whole point of having one: `position().done` means the
|
|
392
391
|
* story ended, and XState delivers no events to a done actor, so an `on: {
|
|
393
|
-
* SETUP }` there would have been dead config that looked live.
|
|
392
|
+
* SETUP }` there would have been dead config that looked live. A resumed run
|
|
394
393
|
* resets for the same reason. `SETUP` therefore appears once, on the only state
|
|
395
394
|
* that can be transitioned out of.
|
|
396
395
|
*/
|
|
@@ -450,65 +449,18 @@ export const storyFlow = dialog("story", storySpec);
|
|
|
450
449
|
*/
|
|
451
450
|
export type FrozenGameState = DeepReadonly<GameState>;
|
|
452
451
|
|
|
453
|
-
// ──
|
|
454
|
-
// save_game / load_game
|
|
455
|
-
//
|
|
456
|
-
//
|
|
452
|
+
// ── No cross-session saves, and why ─────────────────────────────────────────
|
|
453
|
+
// `save_game` / `load_game` stood here, keyed by slot name and backed by an
|
|
454
|
+
// `app_state` table through `ctx.db`. Both are gone with `ctx.db` itself: the
|
|
455
|
+
// platform provides tool code no database, and a template cannot reach one — the
|
|
456
|
+
// scaffold ships no Postgres client, and shipped template code cannot import
|
|
457
|
+
// `@alexkroman1/aai-runtime` (templates type-check under the scaffold tsconfig,
|
|
458
|
+
// which that package's source is not clean under).
|
|
457
459
|
//
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
export function saveSlotKey(slot?: string): string {
|
|
463
|
-
return `save:${slot ?? "autosave"}`;
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
/** The slot-name grammar, shared by save_game and load_game so a name that
|
|
467
|
-
* can be saved can always be loaded. */
|
|
468
|
-
export const saveSlotParam = z
|
|
469
|
-
.string()
|
|
470
|
-
.regex(/^[A-Za-z0-9_-]{1,32}$/, "letters, digits, dashes, underscores; max 32 chars")
|
|
471
|
-
.describe("Save slot name, defaults to autosave")
|
|
472
|
-
.optional();
|
|
473
|
-
|
|
474
|
-
const ENSURE_APP_STATE = `create table if not exists app_state (
|
|
475
|
-
key text primary key,
|
|
476
|
-
value jsonb not null,
|
|
477
|
-
updated_at timestamptz not null default now()
|
|
478
|
-
)`;
|
|
479
|
-
|
|
480
|
-
// Memoized per process (each session's tools run in a fresh sandbox, so this
|
|
481
|
-
// is at most one round-trip per session); a failure clears the memo so the
|
|
482
|
-
// next call retries instead of caching the error forever.
|
|
483
|
-
let ensureP: Promise<unknown> | null = null;
|
|
484
|
-
function ensureTable(ctx: ToolContext): Promise<unknown> {
|
|
485
|
-
ensureP ??= ctx.db.query(ENSURE_APP_STATE).catch((err) => {
|
|
486
|
-
ensureP = null;
|
|
487
|
-
throw err;
|
|
488
|
-
});
|
|
489
|
-
return ensureP;
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
/** Read one saved value. jsonb columns come back from the postgres driver
|
|
493
|
-
* already parsed, so the value needs no JSON.parse here. */
|
|
494
|
-
export async function loadState<T>(ctx: ToolContext, key: string): Promise<T | null> {
|
|
495
|
-
await ensureTable(ctx);
|
|
496
|
-
const rows = await ctx.db.query<{ value: T }>("select value from app_state where key = $1", [
|
|
497
|
-
key,
|
|
498
|
-
]);
|
|
499
|
-
return rows[0]?.value ?? null;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
/** Upsert one value. Serialized explicitly and cast with `::jsonb` so the
|
|
503
|
-
* write is driver-agnostic about object parameters. */
|
|
504
|
-
export async function saveState(ctx: ToolContext, key: string, value: unknown): Promise<void> {
|
|
505
|
-
await ensureTable(ctx);
|
|
506
|
-
await ctx.db.query(
|
|
507
|
-
"insert into app_state (key, value, updated_at) values ($1, $2::jsonb, now()) " +
|
|
508
|
-
"on conflict (key) do update set value = excluded.value, updated_at = now()",
|
|
509
|
-
[key, JSON.stringify(value)],
|
|
510
|
-
);
|
|
511
|
-
}
|
|
460
|
+
// So this adventure is SINGLE-SESSION: everything lives in `sessionSlot`s and
|
|
461
|
+
// ends when the call does. An author who wants saves adds a client of their own
|
|
462
|
+
// (`postgres`, `pg`, a provider SDK) and a `DATABASE_URL` secret — which is the
|
|
463
|
+
// supported pattern, just not one a shipped template can demonstrate.
|
|
512
464
|
|
|
513
465
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
514
466
|
export function d(sides: number): number {
|
|
@@ -54,7 +54,6 @@ describe("the declaration", () => {
|
|
|
54
54
|
// The page calls `api.start("spokenSummary", …)`, so a rename here is a
|
|
55
55
|
// runtime 400 rather than a compile error. This is what pins it.
|
|
56
56
|
expect(Object.keys(agentDef.workflows ?? {})).toEqual(["spokenSummary"]);
|
|
57
|
-
expect(agentDef.page).toBe("static");
|
|
58
57
|
});
|
|
59
58
|
|
|
60
59
|
test("declares no providers and exactly the one credential its steps read", () => {
|
|
@@ -41,10 +41,11 @@
|
|
|
41
41
|
* three services this uses: transcription, the LLM Gateway, and the voice.
|
|
42
42
|
* `requiredEnv` below is what makes a deploy check for it rather than letting
|
|
43
43
|
* the first run find out.
|
|
44
|
-
* - **
|
|
45
|
-
*
|
|
46
|
-
* workflow apps: an upload's record is a row, and this
|
|
47
|
-
* BOTH ends — the recording coming in and the summary
|
|
44
|
+
* - **A `DATABASE_URL`** — a secret when deployed, `.env` under `aai dev`, and a
|
|
45
|
+
* Postgres you bring since the platform provisions none. REQUIRED here, and
|
|
46
|
+
* more so than for most workflow apps: an upload's record is a row, and this
|
|
47
|
+
* app uses uploads at BOTH ends — the recording coming in and the summary
|
|
48
|
+
* going out.
|
|
48
49
|
*
|
|
49
50
|
* ## The recording is UPLOADED, and the run carries its id
|
|
50
51
|
*
|
|
@@ -37,7 +37,6 @@ import "@alexkroman1/aai-ui/styles.css";
|
|
|
37
37
|
// nothing — and it is what stops this file restating a shape
|
|
38
38
|
// `workflows/summarize.ts` already declares.
|
|
39
39
|
import { formatDuration } from "@alexkroman1/aai/utils";
|
|
40
|
-
import type { WorkflowOutputOf } from "@alexkroman1/aai/workflow-api";
|
|
41
40
|
import {
|
|
42
41
|
createWorkflowApi,
|
|
43
42
|
Form,
|
|
@@ -51,9 +50,6 @@ import {
|
|
|
51
50
|
} from "@alexkroman1/aai-ui";
|
|
52
51
|
import type { spokenSummary } from "./agent.ts";
|
|
53
52
|
|
|
54
|
-
/** What a completed run reports, derived from the workflow rather than restated. */
|
|
55
|
-
type Summary = WorkflowOutputOf<typeof spokenSummary>;
|
|
56
|
-
|
|
57
53
|
/**
|
|
58
54
|
* The workflow's name, as a page starts a run by one.
|
|
59
55
|
*
|
|
@@ -88,8 +84,9 @@ function captionsUrl(text: string, durationMs: number): string {
|
|
|
88
84
|
export function App() {
|
|
89
85
|
// The generic is what makes `run.status === "completed"` narrow to a TYPED
|
|
90
86
|
// `run.output` instead of `unknown`.
|
|
91
|
-
const {
|
|
92
|
-
|
|
87
|
+
const { submitForm, run, pending, upload, pauseUpload, resumeUpload, error } = useWorkflowSubmit<
|
|
88
|
+
typeof spokenSummary
|
|
89
|
+
>(WORKFLOW, { api });
|
|
93
90
|
const output = run?.status === "completed" ? run.output : undefined;
|
|
94
91
|
// `useDownloadUrl` is the SDK's: the byte route takes the agent's bearer, so the
|
|
95
92
|
// bytes have to be FETCHED and handed to the element as an object URL — and the
|
|
@@ -105,7 +102,7 @@ export function App() {
|
|
|
105
102
|
</p>
|
|
106
103
|
</header>
|
|
107
104
|
|
|
108
|
-
<Form onSubmit={
|
|
105
|
+
<Form onSubmit={submitForm} error={error} className="flex flex-col gap-4">
|
|
109
106
|
{/* Every control, from the workflow's own input schema. See the module doc. */}
|
|
110
107
|
<WorkflowFields workflow={WORKFLOW} />
|
|
111
108
|
<SubmitButton pending={pending} pendingLabel="Working…">
|