@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.
Files changed (90) hide show
  1. package/dist/_dev-env.d.ts +15 -2
  2. package/dist/{_dev-server-Dt3TCjhj.mjs → _dev-server-BAR8Ya43.mjs} +182 -55
  3. package/dist/_dev-server.d.ts +5 -30
  4. package/dist/_dev-typecheck.d.ts +38 -0
  5. package/dist/_dev-vite-config.d.ts +74 -0
  6. package/dist/_e2e-test-utils.d.ts +33 -1
  7. package/dist/_init-BNotRgV1.mjs +163 -0
  8. package/dist/_resource-commands.d.ts +0 -1
  9. package/dist/{build-BBxf8vom.mjs → build-DvDdSwAS.mjs} +1 -1
  10. package/dist/cli.mjs +22 -93
  11. package/dist/{delete-CPf347lo.mjs → delete-8pjH8n8n.mjs} +2 -1
  12. package/dist/{deploy-BqV6ShIe.mjs → deploy-CjU3e-3P.mjs} +3 -3
  13. package/dist/{dev-QuuOOUtv.mjs → dev-dFzdvwa7.mjs} +3 -2
  14. package/dist/dev.d.ts +2 -0
  15. package/dist/{eval-DfQ8cm5p.mjs → eval-DwNwdqmS.mjs} +1 -1
  16. package/dist/{init-Cp5GZg0B.mjs → init-DK7GpIF9.mjs} +7 -6
  17. package/dist/scaffold/.env.example +7 -4
  18. package/dist/scaffold/CLAUDE.md +152 -97
  19. package/dist/scaffold/global.d.ts +12 -0
  20. package/dist/scaffold/package.json +4 -4
  21. package/dist/scaffold/server.mjs +49 -2
  22. package/dist/scaffold/tsconfig.json +1 -0
  23. package/dist/scaffold/vitest.config.ts +24 -0
  24. package/dist/{studio-DUfCH9I8.mjs → studio-D3-HWIfk.mjs} +3 -3
  25. package/dist/templates/briefing-desk/agent.test.ts +3 -15
  26. package/dist/templates/briefing-desk/shared.ts +3 -3
  27. package/dist/templates/call-audit/agent.eval.test.ts +8 -1
  28. package/dist/templates/call-audit/agent.test.ts +0 -1
  29. package/dist/templates/call-audit/agent.ts +4 -3
  30. package/dist/templates/call-audit/client.tsx +4 -7
  31. package/dist/templates/call-audit/workflows/audit.ts +10 -1
  32. package/dist/templates/code-interpreter/agent.eval.test.ts +14 -43
  33. package/dist/templates/dispatch-center/agent.eval.test.ts +16 -26
  34. package/dist/templates/embedded-assets/agent.eval.test.ts +6 -16
  35. package/dist/templates/embedded-assets/agent.test.ts +3 -16
  36. package/dist/templates/health-assistant/agent.eval.test.ts +5 -15
  37. package/dist/templates/health-assistant/agent.test.ts +3 -14
  38. package/dist/templates/infocom-adventure/agent.eval.test.ts +6 -16
  39. package/dist/templates/infocom-adventure/agent.test.ts +3 -17
  40. package/dist/templates/link-digest/agent.test.ts +0 -14
  41. package/dist/templates/link-digest/agent.ts +9 -5
  42. package/dist/templates/link-digest/client.tsx +8 -6
  43. package/dist/templates/math-buddy/agent.eval.test.ts +11 -40
  44. package/dist/templates/night-owl/agent.eval.test.ts +15 -44
  45. package/dist/templates/night-owl/agent.test.ts +2 -15
  46. package/dist/templates/personal-finance/agent.eval.test.ts +10 -39
  47. package/dist/templates/pipeline-simple/agent.test.ts +73 -0
  48. package/dist/templates/pizza-ordering/agent.eval.test.ts +2 -17
  49. package/dist/templates/pizza-ordering/agent.test.ts +2 -17
  50. package/dist/templates/plan-and-execute/agent.eval.test.ts +2 -12
  51. package/dist/templates/plan-and-execute/agent.test.ts +3 -23
  52. package/dist/templates/plan-and-execute/shared.ts +1 -1
  53. package/dist/templates/podcast-digest/agent.test.ts +0 -1
  54. package/dist/templates/podcast-digest/client.tsx +3 -6
  55. package/dist/templates/podcast-digest/workflows/digest.ts +16 -8
  56. package/dist/templates/recap-workflow/agent.eval.test.ts +7 -14
  57. package/dist/templates/recap-workflow/agent.test.ts +3 -18
  58. package/dist/templates/recap-workflow/agent.ts +7 -5
  59. package/dist/templates/redline/agent.test.ts +0 -13
  60. package/dist/templates/redline/agent.ts +3 -2
  61. package/dist/templates/redline/client.tsx +9 -4
  62. package/dist/templates/research-workflow/agent.eval.test.ts +7 -14
  63. package/dist/templates/research-workflow/agent.test.ts +3 -15
  64. package/dist/templates/research-workflow/agent.ts +7 -5
  65. package/dist/templates/research-workflow/workflows/research.ts +8 -2
  66. package/dist/templates/retail/agent.eval.test.ts +10 -25
  67. package/dist/templates/retail/registry.test.ts +16 -13
  68. package/dist/templates/solo-rpg/agent.eval.test.ts +5 -15
  69. package/dist/templates/solo-rpg/agent.test.ts +17 -124
  70. package/dist/templates/solo-rpg/shared.ts +13 -61
  71. package/dist/templates/spoken-summary/agent.test.ts +0 -1
  72. package/dist/templates/spoken-summary/agent.ts +5 -4
  73. package/dist/templates/spoken-summary/client.tsx +4 -7
  74. package/dist/templates/support-line/agent.eval.test.ts +2 -12
  75. package/dist/templates/support-line/agent.test.ts +3 -22
  76. package/dist/templates/transcription-workflow/agent.ts +4 -4
  77. package/dist/templates/transcription-workflow/client.tsx +5 -5
  78. package/dist/templates/transcription-workflow/workflows/transcribe.ts +10 -1
  79. package/dist/templates/travel-concierge/agent.eval.test.ts +5 -15
  80. package/dist/templates/travel-concierge/agent.test.ts +3 -23
  81. package/dist/templates/web-researcher/agent.eval.test.ts +2 -5
  82. package/dist/test-CvwgeVSQ.mjs +181 -0
  83. package/dist/test.d.ts +33 -2
  84. package/package.json +4 -4
  85. package/dist/_init-BhEfQ6Yi.mjs +0 -93
  86. package/dist/storage-Bfs7QQWq.mjs +0 -66
  87. package/dist/storage.d.ts +0 -22
  88. package/dist/templates/solo-rpg/tools/load_game.ts +0 -37
  89. package/dist/templates/solo-rpg/tools/save_game.ts +0 -32
  90. 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): 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<Redline>(WORKFLOW);
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
- const agentDef = withDiscoveredTools(
54
- authoredAgent,
55
- import.meta.glob("./tools/*.ts", { eager: true }),
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
- * The def a DEPLOYED agent runs: authored, plus what `tools/` declares.
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", max_results: 5 });
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
- * Storage is what makes it DURABLE (`aai storage enable`, Settings Database
73
- * in the studio, or `DATABASE_URL` under `aai dev`) runs and the key index
74
- * both live there. Without it both live in the process, so a run in flight is
75
- * lost when the agent restarts, redeploys or goes idle; everything below still
76
- * works, which is what lets you try it before provisioning anything.
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
- /** Angles investigated at once. The far side of every one is a rate limit. */
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
- max_results: SEARCH_RESULTS,
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
- /// <reference types="vite/client" />
2
-
3
- import { withSystemPrompt } from "@alexkroman1/aai/manifest";
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, withDiscoveredTools } from "@alexkroman1/aai/testing";
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: authored, plus what `tools/` declares.
20
+ * The def a DEPLOYED agent runs, lowered BY HAND — the one place in the
21
+ * templates that still does.
24
22
  *
25
- * The glob is written HERE rather than reached for from a shared helper because
26
- * this file SHIPS: it is what a scaffolded project runs, so it may not import
27
- * anything outside its own template, and `import.meta.glob` is expanded against
28
- * the file containing it either way. This is the pattern a user writes.
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
- const retailAgent = withDiscoveredTools(
31
- authoredAgent,
32
- import.meta.glob("./tools/*.ts", { eager: true }),
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
- const agentDef = withSystemPrompt(
47
- withDiscoveredTools(authoredAgent, import.meta.glob("./tools/*.ts", { eager: true })),
48
- systemPrompt,
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 { Db, ToolContext, ToolDef, ToolInputSchema } from "@alexkroman1/aai";
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(db: Db = makeDb().db): ToolContext {
58
- return createToolContext({ db, send: vi.fn() });
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
- // ── save_game / load_game: cross-session persistence via ctx.db ──────────────
600
-
601
- describe("save_game / load_game", () => {
602
- test("a save made in one session loads in a later session", async () => {
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 three of these used to RUN before a character existed: `action_roll`
659
- // rolled 2d6 against the stats of nobody and applied consequences to a game
660
- // that was not there, and `save_game` wrote an empty campaign to a slot
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
- test("a loaded game resumes in play rather than awaiting setup", async () => {
744
- const { db } = makeDb();
745
- const sessionA = makeCtx(db);
746
- seedPlaying(sessionA);
747
- ok(await saveGame.execute({ slot: "resume" }, sessionA));
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, type ToolContext } from "@alexkroman1/aai";
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. `load_game`
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
- // ── Persistent save slots (ctx.db) ───────────────────────────────────────────
454
- // save_game / load_game are genuine cross-session persistence, so they use
455
- // the app's SQL database. Requires storage: `aai storage enable` (or
456
- // Settings Database in the studio); under `aai dev`, set DATABASE_URL in .env.
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
- // Slots are keyed by name alone the whole point of a save is loading it in
459
- // a LATER session, whose sessionId differs, so the key can't embed one. The
460
- // storage is per app, so every player of one deployment shares the slot
461
- // namespace; without player identity that is the price of resumability.
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
- * - **Storage** (`aai storage enable`, Settings Database in the studio, or
45
- * `DATABASE_URL` under `aai dev`). REQUIRED here, and more so than for most
46
- * workflow apps: an upload's record is a row, and this app uses uploads at
47
- * BOTH ends — the recording coming in and the summary going out.
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 { submit, run, pending, upload, pauseUpload, resumeUpload, error } =
92
- useWorkflowSubmit<Summary>(WORKFLOW, { api });
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={submit} error={error} className="flex flex-col gap-4">
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…">