@pylonsync/create-pylon 0.21.0 → 0.22.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/create-pylon",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Scaffold a new Pylon app — realtime backend + web/mobile/expo frontends in one command. Run via `npm create @pylonsync/pylon@latest`.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -254,7 +254,7 @@ function Chat({ selfId, initialChannel }: { selfId: string; initialChannel?: str
254
254
 
255
255
  function remove(id: string) {
256
256
  if (window.confirm("Delete this message? Everyone in the channel will stop seeing it.")) {
257
- void db.delete("Message", id);
257
+ db.delete("Message", id).catch(console.warn);
258
258
  }
259
259
  }
260
260
 
@@ -86,7 +86,7 @@ export function PostMenu({ social, post }: { social: Social; post: Post }) {
86
86
  className={cn(row, "font-semibold text-red-600")}
87
87
  onClick={() => {
88
88
  setOpen(false);
89
- void db.delete("Post", post.id);
89
+ db.delete("Post", post.id).catch(console.warn);
90
90
  if (window.location.pathname.startsWith("/p/")) router.push("/");
91
91
  }}
92
92
  >
@@ -158,7 +158,7 @@ function CommentRow({
158
158
  {mine ? (
159
159
  <button
160
160
  type="button"
161
- onClick={() => void db.delete("Comment", comment.id)}
161
+ onClick={() => db.delete("Comment", comment.id).catch(console.warn)}
162
162
  className="hidden items-center gap-1 hover:text-zinc-900 group-hover:inline-flex"
163
163
  aria-label="Delete comment"
164
164
  >
@@ -66,21 +66,21 @@ export type Social = ReturnType<typeof useSocial>;
66
66
  export const actions = {
67
67
  toggleLike(social: Social, postId: string) {
68
68
  const mine = social.myLikes.get(postId);
69
- if (mine) void db.delete("Like", mine.id);
70
- else if (social.me) void db.insert("Like", { postId, userId: social.me });
69
+ if (mine) db.delete("Like", mine.id).catch(console.warn);
70
+ else if (social.me) db.insert("Like", { postId, userId: social.me }).catch(console.warn);
71
71
  },
72
72
  like(social: Social, postId: string) {
73
- if (social.me && !social.myLikes.has(postId)) void db.insert("Like", { postId, userId: social.me });
73
+ if (social.me && !social.myLikes.has(postId)) db.insert("Like", { postId, userId: social.me }).catch(console.warn);
74
74
  },
75
75
  toggleSave(social: Social, postId: string) {
76
76
  const mine = social.mySaves.get(postId);
77
- if (mine) void db.delete("Save", mine.id);
78
- else if (social.me) void db.insert("Save", { postId, userId: social.me });
77
+ if (mine) db.delete("Save", mine.id).catch(console.warn);
78
+ else if (social.me) db.insert("Save", { postId, userId: social.me }).catch(console.warn);
79
79
  },
80
80
  toggleFollow(social: Social, userId: string) {
81
81
  if (!social.me || userId === social.me) return;
82
82
  const mine = social.myFollows.get(userId);
83
- if (mine) void db.delete("Follow", mine.id);
84
- else void db.insert("Follow", { followingId: userId, followerId: social.me });
83
+ if (mine) db.delete("Follow", mine.id).catch(console.warn);
84
+ else db.insert("Follow", { followingId: userId, followerId: social.me }).catch(console.warn);
85
85
  },
86
86
  };
@@ -42,8 +42,6 @@ IMESSAGE_TRANSPORT=sendblue
42
42
  # IMESSAGE_ALLOW_DEV_SENDS=1
43
43
 
44
44
  # ── Production only ──────────────────────────────────────────────────────────
45
- # The server runs the agent as the owner by calling its own API. It needs its
46
- # public origin and the admin token to open that session. `pylon dev` needs
47
- # neither (it falls back to http://127.0.0.1:$PYLON_PORT).
45
+ # The public origin the Setup page uses for the webhook and relay URLs.
46
+ # `pylon dev` falls back to http://127.0.0.1:$PYLON_PORT.
48
47
  # APP_URL=https://assistant.example.com
49
- # PYLON_ADMIN_TOKEN=
@@ -14,9 +14,10 @@ transports, the owner model, and production setup.
14
14
  on the transport's message id, allowlist and rate limit (`lib/gate.ts`), then
15
15
  schedule `processTurn`. Do not add a second path that skips it.
16
16
  - **The agent is `functions/assistant.ts` (`agent()`, `auth: "admin"`).**
17
- `processTurn` runs it as the owner through `lib/agent-call.ts`. Tools must
18
- resolve their contact with `currentTurn(ctx)` and never accept a contact,
19
- handle, or id from the model.
17
+ `processTurn` runs it as the owner with `ctx.agents.run`, passing the
18
+ conversation and turn as the run's `context`. Tools must resolve their
19
+ contact with `currentTurn(ctx, run)` and never accept a contact, handle, or
20
+ id from the model.
20
21
  - **The dashboard is owner-only.** Every entity policy is `auth.isAdmin`; the
21
22
  owner is the verified email in `PYLON_ADMIN_EMAILS`.
22
23
  - **AppleScript:** `relay/applescript.ts` passes text and handle as argv to a
@@ -55,8 +55,8 @@ real texts arrive.
55
55
  history; after 80 turns a new run starts with the last few texts as context.
56
56
  - **Tools.** `remember_note`, `recall_notes`, `forget_note`, `set_reminder`,
57
57
  `list_reminders`, `cancel_reminder`. They act only on the person being
58
- answered: the contact comes from the server (`functions/resolveTurn.ts`),
59
- never from the model. Reminders run on Pylon's job scheduler
58
+ answered: the contact comes from the server (the run's context and
59
+ `functions/resolveTurn.ts`), never from the model. Reminders run on Pylon's job scheduler
60
60
  (`ctx.scheduler.runAt` → `functions/fireReminder.ts`).
61
61
  - **Replies** are converted from Markdown to plain text and split into up to
62
62
  four bubbles along paragraph breaks (`lib/chunk.ts`).
@@ -64,15 +64,15 @@ real texts arrive.
64
64
  owner is the account whose verified email is in `PYLON_ADMIN_EMAILS`.
65
65
  Anonymous visitors and other accounts read nothing.
66
66
 
67
- ### Why the server calls itself
67
+ ### How a turn runs the agent
68
68
 
69
- `agent()` stores each run under the signed-in user who called it. A webhook
70
- has no user, so `processTurn` opens a session for the owner
71
- (`POST /api/auth/session`) and calls `/api/fn/assistant` over HTTP
72
- (`lib/agent-call.ts`). The session token stays in server memory. In
73
- `pylon dev` this needs nothing. In production set `PYLON_ADMIN_TOKEN` (only the
74
- server uses it, to open that session). The call goes to `127.0.0.1:$PYLON_PORT`
75
- when the port is known, otherwise to `APP_URL`, which must be https.
69
+ `agent()` stores each run under a user. A webhook has no user, so
70
+ `processTurn` (an internal action) calls `ctx.agents.run("assistant", ...)` with
71
+ `as: { userId: <owner>, admin: true }`. The run belongs to the owner and shows
72
+ up live in the dashboard. The call also passes `context: { conversationId,
73
+ turnId }`; the tools read it from their `run` argument, and `resolveTurn`
74
+ maps it to the contact only while that turn is running. Only server code can
75
+ set a run's context.
76
76
 
77
77
  ## Transport 1: Sendblue
78
78
 
@@ -155,7 +155,7 @@ How the relay works (`relay/main.ts`):
155
155
  | `SENDBLUE_*` | The Sendblue transport. |
156
156
  | `RELAY_TOKEN` | The relay transport. At least 24 characters. |
157
157
  | `IMESSAGE_DRY_RUN=1` | Store replies without sending them (both transports). Always on in `pylon dev` unless `IMESSAGE_ALLOW_DEV_SENDS=1`. |
158
- | `PYLON_ADMIN_TOKEN`, `APP_URL` | Production: how the server reaches its own API to run the agent. `APP_URL` must be https and is used only when `PYLON_PORT` is not set. |
158
+ | `APP_URL` | Production: the public origin the Setup page uses for the webhook and relay URLs. |
159
159
 
160
160
  The Setup page shows which of these are set (never their values), when the
161
161
  last text came in, and when the relay last checked in.
@@ -170,8 +170,8 @@ Covers webhook verification (valid, wrong, missing, unconfigured secret),
170
170
  idempotency, the allowlist and rate limit, STOP/START, relay token auth,
171
171
  AppleScript argument passing (including a real `osascript` echo run on macOS
172
172
  with quotes, backslashes, newlines, and unicode), the chat.db reader against a
173
- fixture database built in a temp directory, the agent call over SSE, and
174
- reply chunking. Nothing in the suite sends a message or calls Sendblue.
173
+ fixture database built in a temp directory, the agent turn and its tool
174
+ fence, and reply chunking. Nothing in the suite sends a message or calls Sendblue.
175
175
 
176
176
  ## Deploy
177
177
 
@@ -180,7 +180,7 @@ pylon deploy
180
180
  pylon secrets set PYLON_ADMIN_EMAILS=you@example.com ANTHROPIC_API_KEY=... \
181
181
  IMESSAGE_TRANSPORT=sendblue SENDBLUE_API_KEY=... SENDBLUE_API_SECRET=... \
182
182
  SENDBLUE_FROM_NUMBER=+1... SENDBLUE_WEBHOOK_SECRET=... \
183
- APP_URL=https://<your app> PYLON_ADMIN_TOKEN=<openssl rand -hex 32>
183
+ APP_URL=https://<your app>
184
184
  ```
185
185
 
186
186
  Sign-in codes in production need an email provider (`PYLON_EMAIL_PROVIDER`);
@@ -55,7 +55,7 @@ interface AgentRunRow {
55
55
  steps?: number | null;
56
56
  error?: string | null;
57
57
  updatedAt: string;
58
- title?: string | null;
58
+ context?: { conversationId?: string } | null;
59
59
  }
60
60
  interface AgentMessageRow {
61
61
  id: string;
@@ -242,10 +242,10 @@ export function Console({ view, initialConversationId }: { view: View; initialCo
242
242
  const contact = conversation ? contactById.get(conversation.contactId) : undefined;
243
243
  const threadMessages = conversation ? (messages ?? []).filter((m) => m.conversationId === conversation.id) : [];
244
244
  const unanswered = threadMessages.filter((m) => m.direction === "in" && UNANSWERED.has(m.status)).length;
245
- const runRow = conversation ? (runs ?? []).find((r) => r.id === conversation.agentRunId || r.title === conversation.id) : undefined;
245
+ const runRow = conversation ? (runs ?? []).find((r) => r.id === conversation.agentRunId || r.context?.conversationId === conversation.id) : undefined;
246
246
  const activity: PanelActivity[] = [];
247
247
  if (conversation) {
248
- const runIds = new Set((runs ?? []).filter((r) => r.title === conversation.id).map((r) => r.id));
248
+ const runIds = new Set((runs ?? []).filter((r) => r.id === conversation.agentRunId || r.context?.conversationId === conversation.id).map((r) => r.id));
249
249
  const refusals = new Map<string, string | null>();
250
250
  for (const m of agentMessages ?? []) {
251
251
  if (!runIds.has(m.runId) || m.role !== "user" || !Array.isArray(m.content)) continue;
@@ -9,7 +9,6 @@ export interface SetupStatus {
9
9
  checklist: { key: string; label: string; ok: boolean; hint: string }[];
10
10
  provider: { configured: boolean; provider: string | null };
11
11
  ownerConfigured: boolean;
12
- adminTokenSet: boolean;
13
12
  appUrlSet: boolean;
14
13
  devMode: boolean;
15
14
  baseUrl: string;
@@ -139,7 +138,7 @@ export function SetupView({
139
138
  function StatusGrid({ status, transport }: { status: SetupStatus; transport: TransportStateView | null }) {
140
139
  const online = relayOnline(transport?.relayLastSeenAt ?? null);
141
140
  const transportOk = status.transport !== null && status.checklist.every((c) => c.ok);
142
- const needsProdConfig = !status.devMode && (!status.appUrlSet || !status.adminTokenSet);
141
+ const needsProdConfig = !status.devMode && !status.appUrlSet;
143
142
  return (
144
143
  <div className="mt-8 grid gap-3 sm:grid-cols-2">
145
144
  <StatusCard
@@ -200,13 +199,10 @@ function StatusGrid({ status, transport }: { status: SetupStatus; transport: Tra
200
199
  ok={!needsProdConfig}
201
200
  title="Server"
202
201
  lines={[
203
- `Calls itself at ${status.baseUrl}`,
204
- status.devMode
205
- ? "Development mode: no admin token needed."
206
- : status.adminTokenSet
207
- ? "PYLON_ADMIN_TOKEN set."
208
- : "Set PYLON_ADMIN_TOKEN so the server can open the owner's session for replies.",
209
- ...(!status.devMode && !status.appUrlSet ? ["Set APP_URL to this app's public https origin."] : []),
202
+ `Public URL ${status.baseUrl}`,
203
+ ...(!status.devMode && !status.appUrlSet
204
+ ? ["Set APP_URL to this app's public https origin so the webhook and relay URLs are right."]
205
+ : []),
210
206
  ]}
211
207
  />
212
208
  </div>
@@ -1,30 +1,13 @@
1
- import { agent, v, type ActionCtx } from "@pylonsync/functions";
1
+ import { agent, v } from "@pylonsync/functions";
2
2
  import { ASSISTANT_MODEL, SYSTEM_PROMPT } from "../lib/assistant";
3
-
4
- interface Turn {
5
- conversationId: string;
6
- contactId: string;
7
- }
8
-
9
- // Which contact is this generation about? Every tool starts here. The agent
10
- // loop records its stream id on the AgentRun row it is working on, and
11
- // processTurn created that run with the conversation id as its title, so
12
- // resolveTurn can map this call back to exactly one contact. Nothing the
13
- // model or the texter writes can change that mapping.
14
- async function currentTurn(ctx: ActionCtx): Promise<Turn> {
15
- const streamId = ctx.stream?.id;
16
- if (!streamId) throw new Error("This tool is only available while answering a text.");
17
- const turn = await ctx.runQuery<Turn | null>("resolveTurn", { streamId });
18
- if (!turn) throw new Error("This tool is only available while answering a text.");
19
- return turn;
20
- }
3
+ import { currentTurn } from "../lib/turn";
21
4
 
22
5
  // The assistant. Pylon's agent() runs the tool loop, persists every turn into
23
6
  // AgentRun/AgentMessage (live in the dashboard), and streams tokens.
24
7
  //
25
- // auth "admin": only the owner's session may run it. processTurn calls it as
26
- // the owner for each inbound text (lib/agent-call.ts); any other account gets
27
- // FORBIDDEN before the model is called.
8
+ // auth "admin": only the owner may run it. processTurn runs it as the owner
9
+ // for each inbound text (ctx.agents.run); any other account gets FORBIDDEN
10
+ // before the model is called.
28
11
  //
29
12
  // The tools act only on the contact of the current turn and never take a
30
13
  // contact, phone number, or id from the model.
@@ -40,23 +23,23 @@ export default agent({
40
23
  description:
41
24
  "Save a short fact about the person you are texting (a preference, a name, a date). One fact per call.",
42
25
  args: { text: v.string() },
43
- handler: async (ctx, input) => {
44
- const turn = await currentTurn(ctx);
26
+ handler: async (ctx, input, run) => {
27
+ const turn = await currentTurn(ctx, run);
45
28
  return ctx.runMutation("toolSaveNote", { contactId: turn.contactId, text: String(input.text) });
46
29
  },
47
30
  },
48
31
  recall_notes: {
49
32
  description: "List the facts saved about the person you are texting, newest first.",
50
- handler: async (ctx) => {
51
- const turn = await currentTurn(ctx);
33
+ handler: async (ctx, _input, run) => {
34
+ const turn = await currentTurn(ctx, run);
52
35
  return ctx.runQuery("toolListNotes", { contactId: turn.contactId });
53
36
  },
54
37
  },
55
38
  forget_note: {
56
39
  description: "Delete one saved fact about the person you are texting, by the id recall_notes returned.",
57
40
  args: { noteId: v.string() },
58
- handler: async (ctx, input) => {
59
- const turn = await currentTurn(ctx);
41
+ handler: async (ctx, input, run) => {
42
+ const turn = await currentTurn(ctx, run);
60
43
  return ctx.runMutation("toolForgetNote", { contactId: turn.contactId, noteId: String(input.noteId) });
61
44
  },
62
45
  },
@@ -64,8 +47,8 @@ export default agent({
64
47
  description:
65
48
  "Schedule a text to the person you are texting at a specific time. `when` is ISO 8601 with a UTC offset, e.g. 2026-10-03T09:00:00-05:00. `text` is the exact message they will receive.",
66
49
  args: { when: v.string(), text: v.string() },
67
- handler: async (ctx, input) => {
68
- const turn = await currentTurn(ctx);
50
+ handler: async (ctx, input, run) => {
51
+ const turn = await currentTurn(ctx, run);
69
52
  return ctx.runMutation("toolSetReminder", {
70
53
  contactId: turn.contactId,
71
54
  conversationId: turn.conversationId,
@@ -76,16 +59,16 @@ export default agent({
76
59
  },
77
60
  list_reminders: {
78
61
  description: "List the upcoming reminders for the person you are texting.",
79
- handler: async (ctx) => {
80
- const turn = await currentTurn(ctx);
62
+ handler: async (ctx, _input, run) => {
63
+ const turn = await currentTurn(ctx, run);
81
64
  return ctx.runQuery("toolListReminders", { contactId: turn.contactId });
82
65
  },
83
66
  },
84
67
  cancel_reminder: {
85
68
  description: "Cancel one upcoming reminder for the person you are texting, by the id list_reminders returned.",
86
69
  args: { reminderId: v.string() },
87
- handler: async (ctx, input) => {
88
- const turn = await currentTurn(ctx);
70
+ handler: async (ctx, input, run) => {
71
+ const turn = await currentTurn(ctx, run);
89
72
  return ctx.runMutation("toolCancelReminder", {
90
73
  contactId: turn.contactId,
91
74
  reminderId: String(input.reminderId),
@@ -1,5 +1,5 @@
1
1
  import { action, v } from "@pylonsync/functions";
2
- import { runAgentAsOwner } from "../lib/agent-call";
2
+ import { AGENT_NAME } from "../lib/assistant";
3
3
  import { chunkReply } from "../lib/chunk";
4
4
  import { providerStatus } from "../lib/provider";
5
5
 
@@ -47,27 +47,28 @@ export default action({
47
47
  return { state: "awaiting_setup" };
48
48
  }
49
49
 
50
- const outcome = await runAgentAsOwner(ctx.env, owner.userId, {
50
+ // The run belongs to the owner, so it shows up live in the dashboard.
51
+ // `admin: true` because the assistant is declared auth: "admin".
52
+ // The context tells the tools which conversation and turn they act on
53
+ // (functions/assistant.ts); only server code can set it.
54
+ const result = await ctx.agents.run(AGENT_NAME, {
51
55
  input: begin.input,
52
56
  ...(begin.runId ? { runId: begin.runId } : {}),
53
- // The run's title carries the conversation id. The agent's tools read
54
- // it back (functions/resolveTurn.ts) to know which contact they act on.
55
- title: begin.conversationId,
57
+ as: { userId: owner.userId, admin: true },
58
+ context: { conversationId: begin.conversationId, turnId: begin.turnId },
56
59
  });
57
- if (!outcome.ok) {
58
- await finish({ outcome: "failed", detail: `${outcome.code}: ${outcome.message}`.slice(0, 500) });
59
- return { state: "failed" };
60
- }
61
- const chunks = chunkReply(outcome.result.text);
60
+ const chunks = chunkReply(result.text);
62
61
  if (chunks.length === 0) {
63
- await finish({ outcome: "failed", detail: "The model returned an empty reply", runId: outcome.result.runId });
62
+ await finish({ outcome: "failed", detail: "The model returned an empty reply", runId: result.runId });
64
63
  return { state: "failed" };
65
64
  }
66
- await finish({ outcome: "answered", runId: outcome.result.runId, chunks });
65
+ await finish({ outcome: "answered", runId: result.runId, chunks });
67
66
  return { state: "answered", bubbles: chunks.length };
68
67
  } catch (err) {
68
+ const code = (err as { code?: unknown })?.code;
69
69
  const message = err instanceof Error ? err.message : String(err);
70
- await finish({ outcome: "failed", detail: message.slice(0, 500) });
70
+ const detail = typeof code === "string" ? `${code}: ${message}` : message;
71
+ await finish({ outcome: "failed", detail: detail.slice(0, 500) });
71
72
  return { state: "failed" };
72
73
  }
73
74
  },
@@ -1,25 +1,20 @@
1
1
  import { query, v } from "@pylonsync/functions";
2
- import { AGENT_NAME } from "../lib/assistant";
3
2
 
4
- // Map the agent's current stream id to the conversation it is answering.
5
- // Internal: called by the assistant's tools only (functions/assistant.ts).
3
+ // The contact a tool call acts on. Internal: called by the assistant's tools
4
+ // only (functions/assistant.ts), with the conversation and turn that
5
+ // processTurn put in the agent run's context.
6
6
  //
7
- // Returns null unless all of these hold:
8
- // - an AgentRun of the assistant carries this stream id and belongs to the caller
9
- // - its title is a conversation id (processTurn sets it)
10
- // - that conversation has a turn running right now
7
+ // Returns null unless that conversation exists and that exact turn is still
8
+ // running, so a tool cannot act for a finished turn (for example when the
9
+ // owner continues the run from the dashboard later).
11
10
  export default query({
12
11
  internal: true,
13
12
  auth: "admin",
14
- args: { streamId: v.string() },
13
+ args: { conversationId: v.string(), turnId: v.string() },
15
14
  async handler(ctx, args) {
16
- if (args.streamId === "") return null;
17
- const runs = await ctx.db.query("AgentRun", { streamId: args.streamId, $limit: 2 });
18
- if (runs.length !== 1) return null;
19
- const run = runs[0];
20
- if (run.agent !== AGENT_NAME || run.userId !== ctx.auth.userId || run.status !== "running") return null;
21
- const conversation = await ctx.db.get("Conversation", String(run.title ?? ""));
22
- if (!conversation || conversation.turnState !== "running") return null;
15
+ if (args.conversationId === "" || args.turnId === "") return null;
16
+ const conversation = await ctx.db.get("Conversation", args.conversationId);
17
+ if (!conversation || conversation.turnState !== "running" || conversation.turnId !== args.turnId) return null;
23
18
  return { conversationId: String(conversation.id), contactId: String(conversation.contactId) };
24
19
  },
25
20
  });
@@ -1,8 +1,8 @@
1
1
  import { query } from "@pylonsync/functions";
2
- import { selfBaseUrl } from "../lib/agent-call";
3
2
  import { providerStatus } from "../lib/provider";
4
3
  import { findOwner } from "../lib/store";
5
4
  import { isDevMode, isDryRun, selectedTransport, transportChecklist } from "../lib/transport";
5
+ import { appBaseUrl } from "../lib/urls";
6
6
 
7
7
  // What the server is configured with, for the dashboard's setup panel.
8
8
  // Reports which variables are present, never their values. Owner only.
@@ -13,7 +13,7 @@ export default query({
13
13
  const transport = selectedTransport(ctx.env);
14
14
  const provider = providerStatus(ctx.env);
15
15
  const owner = await findOwner(ctx);
16
- const base = selfBaseUrl(ctx.env);
16
+ const base = appBaseUrl(ctx.env);
17
17
  const devMode = isDevMode(ctx.env);
18
18
  return {
19
19
  transport,
@@ -22,7 +22,6 @@ export default query({
22
22
  provider,
23
23
  ownerConfigured: owner !== null,
24
24
  adminEmailsSet: (ctx.env.PYLON_ADMIN_EMAILS ?? "").trim() !== "",
25
- adminTokenSet: (ctx.env.PYLON_ADMIN_TOKEN ?? "").trim() !== "",
26
25
  appUrlSet: (ctx.env.APP_URL ?? ctx.env.PYLON_PUBLIC_URL ?? "").trim() !== "",
27
26
  devMode,
28
27
  baseUrl: base,
@@ -0,0 +1,25 @@
1
+ import type { ActionCtx, AgentToolRun } from "@pylonsync/functions";
2
+
3
+ export interface Turn {
4
+ conversationId: string;
5
+ contactId: string;
6
+ }
7
+
8
+ const NOT_ANSWERING = "This tool is only available while answering a text.";
9
+
10
+ /**
11
+ * Which contact a tool call is about. Every assistant tool starts here.
12
+ *
13
+ * processTurn runs the agent with ctx.agents.run and puts the conversation
14
+ * and turn in the run's context; only server code can set it. resolveTurn
15
+ * maps that to exactly one contact, and only while that turn is running.
16
+ * Nothing the model or the texter writes can change the mapping.
17
+ */
18
+ export async function currentTurn(ctx: Pick<ActionCtx, "runQuery">, run: AgentToolRun): Promise<Turn> {
19
+ const conversationId = run.context?.conversationId;
20
+ const turnId = run.context?.turnId;
21
+ if (typeof conversationId !== "string" || typeof turnId !== "string") throw new Error(NOT_ANSWERING);
22
+ const turn = await ctx.runQuery<Turn | null>("resolveTurn", { conversationId, turnId });
23
+ if (!turn) throw new Error(NOT_ANSWERING);
24
+ return turn;
25
+ }
@@ -0,0 +1,20 @@
1
+ type Env = Record<string, string | undefined>;
2
+
3
+ /**
4
+ * This app's origin, for the URLs the Setup page shows (the Sendblue webhook
5
+ * and the relay endpoint): APP_URL or PYLON_PUBLIC_URL when set to an http(s)
6
+ * URL, otherwise the local dev server.
7
+ */
8
+ export function appBaseUrl(env: Env): string {
9
+ const raw = (env.APP_URL || env.PYLON_PUBLIC_URL || "").trim();
10
+ if (raw !== "") {
11
+ try {
12
+ const u = new URL(raw);
13
+ if (u.protocol === "https:" || u.protocol === "http:") return u.origin;
14
+ } catch {
15
+ // Fall through to the local default.
16
+ }
17
+ }
18
+ const port = Number.parseInt(env.PYLON_PORT ?? "", 10);
19
+ return `http://127.0.0.1:${Number.isFinite(port) && port > 0 && port < 65536 ? port : 4321}`;
20
+ }
@@ -0,0 +1,141 @@
1
+ // The agent turn: processTurn runs the assistant as the owner through
2
+ // ctx.agents.run, and the tools find their contact from the run's context.
3
+ import { describe, expect, test } from "bun:test";
4
+ import processTurn from "../functions/processTurn";
5
+ import resolveTurn from "../functions/resolveTurn";
6
+ import { chunkReply } from "../lib/chunk";
7
+ import { currentTurn } from "../lib/turn";
8
+ import { appBaseUrl } from "../lib/urls";
9
+ import { fakeCtx } from "./fake-ctx";
10
+
11
+ type Handler = (ctx: unknown, args: Record<string, unknown>) => Promise<any>;
12
+ const run = (def: unknown, ctx: unknown, args: Record<string, unknown>) =>
13
+ (def as { handler: Handler }).handler(ctx, args);
14
+
15
+ const STARTED = {
16
+ state: "started",
17
+ turnId: "turn_1",
18
+ conversationId: "conv_1",
19
+ runId: null,
20
+ input: "[iMessage from Ana] hi",
21
+ };
22
+
23
+ /** An action ctx for processTurn with the agent call stubbed. */
24
+ function turnCtx(agentsRun: (name: string, options: Record<string, unknown>) => Promise<unknown>) {
25
+ const finished: Record<string, unknown>[] = [];
26
+ const agentCalls: { name: string; options: Record<string, unknown> }[] = [];
27
+ const ctx = {
28
+ env: { ANTHROPIC_API_KEY: "sk-test" },
29
+ async runMutation(name: string, args: Record<string, unknown>) {
30
+ if (name === "beginTurn") return STARTED;
31
+ if (name === "finishTurn") {
32
+ finished.push(args);
33
+ return {};
34
+ }
35
+ throw new Error(`unexpected mutation ${name}`);
36
+ },
37
+ async runQuery(name: string) {
38
+ if (name === "ownerAccount") return { userId: "owner_1" };
39
+ throw new Error(`unexpected query ${name}`);
40
+ },
41
+ agents: {
42
+ run(name: string, options: Record<string, unknown>) {
43
+ agentCalls.push({ name, options });
44
+ return agentsRun(name, options);
45
+ },
46
+ },
47
+ };
48
+ return { ctx, finished, agentCalls };
49
+ }
50
+
51
+ describe("processTurn", () => {
52
+ test("runs the assistant as the owner with the turn as context", async () => {
53
+ const reply = "Hello Ana.\n\nSee you soon.";
54
+ const t = turnCtx(async () => ({ runId: "run_1", text: reply, steps: 1 }));
55
+ const out = await run(processTurn, t.ctx, { conversationId: "conv_1" });
56
+ expect(out).toEqual({ state: "answered", bubbles: chunkReply(reply).length });
57
+ expect(t.agentCalls).toEqual([
58
+ {
59
+ name: "assistant",
60
+ options: {
61
+ input: STARTED.input,
62
+ as: { userId: "owner_1", admin: true },
63
+ context: { conversationId: "conv_1", turnId: "turn_1" },
64
+ },
65
+ },
66
+ ]);
67
+ expect(t.finished).toEqual([
68
+ {
69
+ conversationId: "conv_1",
70
+ turnId: "turn_1",
71
+ outcome: "answered",
72
+ runId: "run_1",
73
+ chunks: chunkReply(reply),
74
+ },
75
+ ]);
76
+ });
77
+
78
+ test("records an agent failure on the turn instead of throwing", async () => {
79
+ const t = turnCtx(async () => {
80
+ throw Object.assign(new Error("Upstream LLM provider returned an error."), { code: "PROVIDER_HTTP_529" });
81
+ });
82
+ const out = await run(processTurn, t.ctx, { conversationId: "conv_1" });
83
+ expect(out).toEqual({ state: "failed" });
84
+ expect(t.finished[0]).toMatchObject({
85
+ outcome: "failed",
86
+ detail: "PROVIDER_HTTP_529: Upstream LLM provider returned an error.",
87
+ });
88
+ });
89
+ });
90
+
91
+ describe("the tool fence", () => {
92
+ test("resolveTurn maps a running turn to its contact and nothing else", async () => {
93
+ const f = fakeCtx();
94
+ const contactId = await f.ctx.db.insert("Contact", { handle: "+15125550148" });
95
+ const conversationId = await f.ctx.db.insert("Conversation", {
96
+ contactId,
97
+ turnState: "running",
98
+ turnId: "turn_1",
99
+ });
100
+ expect(await run(resolveTurn, f.ctx, { conversationId, turnId: "turn_1" })).toEqual({ conversationId, contactId });
101
+ // An older turn of the same conversation.
102
+ expect(await run(resolveTurn, f.ctx, { conversationId, turnId: "turn_0" })).toBeNull();
103
+ // The turn finished.
104
+ await f.ctx.db.update("Conversation", conversationId, { turnState: "idle" });
105
+ expect(await run(resolveTurn, f.ctx, { conversationId, turnId: "turn_1" })).toBeNull();
106
+ expect(await run(resolveTurn, f.ctx, { conversationId: "missing", turnId: "turn_1" })).toBeNull();
107
+ });
108
+
109
+ test("currentTurn needs the server-set context", async () => {
110
+ const queries: unknown[] = [];
111
+ const ctx = {
112
+ async runQuery(name: string, args: unknown) {
113
+ queries.push([name, args]);
114
+ return { conversationId: "conv_1", contactId: "contact_1" };
115
+ },
116
+ } as never;
117
+ const base = { runId: "run_1", agent: "assistant", userId: "owner_1" };
118
+
119
+ expect(
120
+ await currentTurn(ctx, { ...base, context: { conversationId: "conv_1", turnId: "turn_1" } }),
121
+ ).toEqual({ conversationId: "conv_1", contactId: "contact_1" });
122
+ expect(queries).toEqual([["resolveTurn", { conversationId: "conv_1", turnId: "turn_1" }]]);
123
+
124
+ // A run a client started (no context), or a partial one, gets nothing.
125
+ await expect(currentTurn(ctx, { ...base, context: null })).rejects.toThrow("only available while answering");
126
+ await expect(currentTurn(ctx, { ...base, context: { conversationId: "conv_1" } })).rejects.toThrow(
127
+ "only available while answering",
128
+ );
129
+ expect(queries.length).toBe(1);
130
+ });
131
+ });
132
+
133
+ describe("appBaseUrl", () => {
134
+ test("prefers the configured public origin, then the local dev server", () => {
135
+ expect(appBaseUrl({ APP_URL: "https://a.example.com/path", PYLON_PORT: "4541" })).toBe("https://a.example.com");
136
+ expect(appBaseUrl({ PYLON_PUBLIC_URL: "https://b.example.com" })).toBe("https://b.example.com");
137
+ expect(appBaseUrl({ PYLON_PORT: "4541" })).toBe("http://127.0.0.1:4541");
138
+ expect(appBaseUrl({ APP_URL: "javascript:alert(1)" })).toBe("http://127.0.0.1:4321");
139
+ expect(appBaseUrl({})).toBe("http://127.0.0.1:4321");
140
+ });
141
+ });
@@ -156,9 +156,9 @@ export function InvoiceView({
156
156
  value={invoice.clientId ?? ""}
157
157
  className="h-8 bg-background md:h-7.5"
158
158
  onChange={(event) =>
159
- void db.update("Invoice", invoice.id, {
159
+ db.update("Invoice", invoice.id, {
160
160
  clientId: event.target.value || null,
161
- })
161
+ }).catch(console.warn)
162
162
  }
163
163
  >
164
164
  <option value="">—</option>
@@ -66,9 +66,9 @@ function Inner({
66
66
  return;
67
67
  }
68
68
  if (mine) {
69
- void db.delete("Watch", mine.id);
69
+ db.delete("Watch", mine.id).catch(console.warn);
70
70
  } else {
71
- void db.insert("Watch", { userId: identity.userId, listingId, listingTitle });
71
+ db.insert("Watch", { userId: identity.userId, listingId, listingTitle }).catch(console.warn);
72
72
  }
73
73
  }
74
74
 
@@ -93,9 +93,9 @@ export function ProjectView({
93
93
  value={project.status}
94
94
  className="h-8 md:h-7.5"
95
95
  onChange={(event) =>
96
- void db.update("Project", project.id, {
96
+ db.update("Project", project.id, {
97
97
  status: event.target.value,
98
- })
98
+ }).catch(console.warn)
99
99
  }
100
100
  >
101
101
  {PROJECT_STATUSES.map((s) => (
@@ -1,251 +0,0 @@
1
- // Run the `assistant` agent for an inbound text.
2
- //
3
- // `agent()` persists every turn into AgentRun/AgentMessage rows owned by the
4
- // caller, so it needs a signed-in user. Inbound texts arrive on a webhook or a
5
- // relay sync, where there is no user. The server therefore calls the agent
6
- // over HTTP as the owner: it mints a session for the owner's user id with
7
- // POST /api/auth/session (open in `pylon dev`; requires PYLON_ADMIN_TOKEN in
8
- // production), then POSTs /api/fn/assistant with that session. The owner's
9
- // session resolves to an admin (PYLON_ADMIN_EMAILS), which is the only caller
10
- // the agent accepts. The runs then show up live in the owner's dashboard
11
- // through the framework's owner-scoped AgentRun/AgentMessage sync.
12
- //
13
- // The session token lives only in this process's memory and is reused until
14
- // the server rejects it.
15
-
16
- import { AGENT_NAME } from "./assistant";
17
-
18
- type Env = Record<string, string | undefined>;
19
-
20
- export interface AgentResult {
21
- runId: string;
22
- text: string;
23
- steps: number;
24
- queued?: boolean;
25
- cancelled?: boolean;
26
- }
27
-
28
- export type AgentCallOutcome =
29
- | { ok: true; result: AgentResult }
30
- | { ok: false; code: string; message: string };
31
-
32
- export type FetchLike = (url: string, init: RequestInit) => Promise<Response>;
33
-
34
- /**
35
- * Origin the server calls itself on. The request carries PYLON_ADMIN_TOKEN, so
36
- * it stays on this machine whenever the port is known (PYLON_PORT). Otherwise
37
- * it goes to APP_URL / PYLON_PUBLIC_URL, and only over https: a plain-http
38
- * public origin would put the admin token on the wire. Falls back to the
39
- * default local port.
40
- */
41
- export function selfBaseUrl(env: Env): string {
42
- const port = Number.parseInt(env.PYLON_PORT ?? "", 10);
43
- if (Number.isFinite(port) && port > 0 && port < 65536) return `http://127.0.0.1:${port}`;
44
- const raw = (env.APP_URL || env.PYLON_PUBLIC_URL || "").trim();
45
- if (raw !== "") {
46
- try {
47
- const u = new URL(raw);
48
- if (u.protocol === "https:") return u.origin;
49
- } catch {
50
- // Fall through to the local default.
51
- }
52
- }
53
- return "http://127.0.0.1:4321";
54
- }
55
-
56
- export interface SseEvent {
57
- event: string;
58
- data: string;
59
- }
60
-
61
- /** Parse a complete text/event-stream body into events. Comment and id lines are dropped. */
62
- export function parseSse(body: string): SseEvent[] {
63
- const events: SseEvent[] = [];
64
- for (const block of body.replace(/\r\n?/g, "\n").split("\n\n")) {
65
- let event = "message";
66
- const data: string[] = [];
67
- for (const line of block.split("\n")) {
68
- if (line.startsWith("event:")) event = line.slice(6).trim();
69
- else if (line.startsWith("data:")) data.push(line.slice(5).replace(/^ /, ""));
70
- }
71
- if (data.length > 0) events.push({ event, data: data.join("\n") });
72
- }
73
- return events;
74
- }
75
-
76
- /** True once `body` holds a complete `result` or `error` event. */
77
- export function hasTerminalEvent(body: string): boolean {
78
- const complete = body.replace(/\r\n?/g, "\n");
79
- const end = complete.lastIndexOf("\n\n");
80
- if (end === -1) return false;
81
- return parseSse(complete.slice(0, end)).some((e) => e.event === "result" || e.event === "error");
82
- }
83
-
84
- /**
85
- * Read an SSE response until its `result` or `error` event, then stop. The
86
- * server can hold a finished stream open (it stays resumable for a while), so
87
- * waiting for the connection to close would hang.
88
- */
89
- async function readUntilTerminalEvent(res: Response): Promise<string> {
90
- if (!res.body) return "";
91
- const reader = res.body.getReader();
92
- const decoder = new TextDecoder();
93
- let body = "";
94
- try {
95
- for (;;) {
96
- const { value, done } = await reader.read();
97
- if (done) break;
98
- body += decoder.decode(value, { stream: true });
99
- if (hasTerminalEvent(body)) break;
100
- }
101
- } finally {
102
- await reader.cancel().catch(() => {});
103
- }
104
- return body;
105
- }
106
-
107
- function asResult(value: unknown): AgentResult | null {
108
- if (typeof value !== "object" || value === null) return null;
109
- const v = value as Record<string, unknown>;
110
- if (typeof v.runId !== "string") return null;
111
- return {
112
- runId: v.runId,
113
- text: typeof v.text === "string" ? v.text : "",
114
- steps: typeof v.steps === "number" ? v.steps : 0,
115
- queued: v.queued === true,
116
- cancelled: v.cancelled === true,
117
- };
118
- }
119
-
120
- function asError(value: unknown, fallback: string): { code: string; message: string } {
121
- const v = (value ?? {}) as Record<string, unknown>;
122
- const e = (typeof v.error === "object" && v.error !== null ? v.error : v) as Record<string, unknown>;
123
- return {
124
- code: typeof e.code === "string" ? e.code : "AGENT_FAILED",
125
- message: typeof e.message === "string" ? e.message : fallback,
126
- };
127
- }
128
-
129
- /** Interpret the agent endpoint's response: SSE when it streamed, JSON otherwise. */
130
- export function readAgentResponse(status: number, contentType: string, body: string): AgentCallOutcome {
131
- if (contentType.includes("text/event-stream")) {
132
- const events = parseSse(body);
133
- const result = events.find((e) => e.event === "result");
134
- if (result) {
135
- try {
136
- const parsed = asResult(JSON.parse(result.data));
137
- if (parsed) return { ok: true, result: parsed };
138
- } catch {
139
- // Reported below.
140
- }
141
- }
142
- const error = events.find((e) => e.event === "error");
143
- if (error) {
144
- try {
145
- return { ok: false, ...asError(JSON.parse(error.data), error.data) };
146
- } catch {
147
- return { ok: false, code: "AGENT_FAILED", message: error.data.slice(0, 300) };
148
- }
149
- }
150
- return { ok: false, code: "AGENT_NO_RESULT", message: "The agent stream ended without a result" };
151
- }
152
- let json: unknown = null;
153
- try {
154
- json = JSON.parse(body);
155
- } catch {
156
- return { ok: false, code: `HTTP_${status}`, message: body.slice(0, 300) || `HTTP ${status}` };
157
- }
158
- if (status >= 200 && status < 300) {
159
- const parsed = asResult(json);
160
- if (parsed) return { ok: true, result: parsed };
161
- }
162
- return { ok: false, ...asError(json, `HTTP ${status}`) };
163
- }
164
-
165
- interface CachedSession {
166
- userId: string;
167
- token: string;
168
- }
169
-
170
- let cached: CachedSession | null = null;
171
-
172
- async function mintSession(
173
- base: string,
174
- userId: string,
175
- adminToken: string,
176
- fetchImpl: FetchLike,
177
- ): Promise<string> {
178
- const headers: Record<string, string> = { "content-type": "application/json" };
179
- if (adminToken) headers.authorization = `Bearer ${adminToken}`;
180
- const res = await fetchImpl(`${base}/api/auth/session`, {
181
- method: "POST",
182
- headers,
183
- body: JSON.stringify({ user_id: userId }),
184
- signal: AbortSignal.timeout(15_000),
185
- });
186
- const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
187
- if (!res.ok || typeof body.token !== "string") {
188
- const err = asError(body, `HTTP ${res.status}`);
189
- throw Object.assign(new Error(err.message), {
190
- code: res.status === 403 ? "OWNER_SESSION_FORBIDDEN" : err.code,
191
- });
192
- }
193
- return body.token;
194
- }
195
-
196
- export interface AgentCallArgs {
197
- input: string;
198
- runId?: string;
199
- title?: string;
200
- }
201
-
202
- export async function runAgentAsOwner(
203
- env: Env,
204
- ownerUserId: string,
205
- args: AgentCallArgs,
206
- fetchImpl: FetchLike = fetch,
207
- ): Promise<AgentCallOutcome> {
208
- const base = selfBaseUrl(env);
209
- const adminToken = (env.PYLON_ADMIN_TOKEN ?? "").trim();
210
-
211
- for (let attempt = 0; attempt < 2; attempt++) {
212
- if (!cached || cached.userId !== ownerUserId) {
213
- try {
214
- cached = { userId: ownerUserId, token: await mintSession(base, ownerUserId, adminToken, fetchImpl) };
215
- } catch (err) {
216
- const e = err as { code?: string; message?: string };
217
- return { ok: false, code: e.code ?? "OWNER_SESSION_FAILED", message: e.message ?? String(err) };
218
- }
219
- }
220
- let res: Response;
221
- try {
222
- res = await fetchImpl(`${base}/api/fn/${AGENT_NAME}`, {
223
- method: "POST",
224
- headers: {
225
- "content-type": "application/json",
226
- accept: "text/event-stream",
227
- authorization: `Bearer ${cached.token}`,
228
- },
229
- body: JSON.stringify(args),
230
- signal: AbortSignal.timeout(280_000),
231
- });
232
- } catch (err) {
233
- return { ok: false, code: "AGENT_UNREACHABLE", message: err instanceof Error ? err.message : String(err) };
234
- }
235
- const contentType = res.headers.get("content-type") ?? "";
236
- const body = contentType.includes("text/event-stream") ? await readUntilTerminalEvent(res) : await res.text();
237
- const outcome = readAgentResponse(res.status, contentType, body);
238
- // A session the server no longer accepts: mint a new one and retry once.
239
- if (!outcome.ok && (res.status === 401 || res.status === 403) && attempt === 0) {
240
- cached = null;
241
- continue;
242
- }
243
- return outcome;
244
- }
245
- return { ok: false, code: "AGENT_AUTH_FAILED", message: "Could not authenticate as the owner" };
246
- }
247
-
248
- /** Test seam: forget the cached owner session. */
249
- export function resetOwnerSessionCache(): void {
250
- cached = null;
251
- }
@@ -1,112 +0,0 @@
1
- import { beforeEach, describe, expect, test } from "bun:test";
2
- import {
3
- hasTerminalEvent,
4
- parseSse,
5
- readAgentResponse,
6
- resetOwnerSessionCache,
7
- runAgentAsOwner,
8
- selfBaseUrl,
9
- } from "../lib/agent-call";
10
-
11
- const RESULT = { runId: "run_1", text: "Hi there", steps: 1, usage: { input_tokens: 1, output_tokens: 1 } };
12
- const SSE_BODY = `retry: 1000\n\nid: 1\ndata: Hi \n\nid: 2\ndata: there\n\nid: 3\nevent: result\ndata: ${JSON.stringify(RESULT)}\n\n`;
13
-
14
- beforeEach(() => resetOwnerSessionCache());
15
-
16
- describe("SSE parsing", () => {
17
- test("parses events and multi-line data", () => {
18
- expect(parseSse("event: tool\ndata: a\ndata: b\n\ndata: plain\n\n")).toEqual([
19
- { event: "tool", data: "a\nb" },
20
- { event: "message", data: "plain" },
21
- ]);
22
- });
23
-
24
- test("detects a complete terminal event only", () => {
25
- expect(hasTerminalEvent(SSE_BODY)).toBe(true);
26
- expect(hasTerminalEvent(SSE_BODY.slice(0, -2))).toBe(false);
27
- expect(hasTerminalEvent("data: x\n\n")).toBe(false);
28
- });
29
-
30
- test("reads a streamed result, a streamed error, and a JSON error", () => {
31
- expect(readAgentResponse(200, "text/event-stream", SSE_BODY)).toEqual({
32
- ok: true,
33
- result: { runId: "run_1", text: "Hi there", steps: 1, queued: false, cancelled: false },
34
- });
35
- expect(
36
- readAgentResponse(200, "text/event-stream", `event: error\ndata: {"code":"LLM_NOT_CONFIGURED","message":"no key"}\n\n`),
37
- ).toEqual({ ok: false, code: "LLM_NOT_CONFIGURED", message: "no key" });
38
- expect(readAgentResponse(403, "application/json", `{"error":{"code":"FORBIDDEN","message":"admin only"}}`)).toEqual({
39
- ok: false,
40
- code: "FORBIDDEN",
41
- message: "admin only",
42
- });
43
- });
44
- });
45
-
46
- describe("selfBaseUrl", () => {
47
- test("prefers loopback, then an https public origin, never plain-http public", () => {
48
- expect(selfBaseUrl({ PYLON_PORT: "4541", APP_URL: "https://a.example.com" })).toBe("http://127.0.0.1:4541");
49
- expect(selfBaseUrl({ APP_URL: "https://a.example.com/path" })).toBe("https://a.example.com");
50
- expect(selfBaseUrl({ PYLON_PUBLIC_URL: "https://b.example.com" })).toBe("https://b.example.com");
51
- expect(selfBaseUrl({ APP_URL: "http://a.example.com" })).toBe("http://127.0.0.1:4321");
52
- expect(selfBaseUrl({ APP_URL: "javascript:alert(1)" })).toBe("http://127.0.0.1:4321");
53
- });
54
- });
55
-
56
- /** A streaming response that sends `body` and then never closes. */
57
- function openStream(body: string): Response {
58
- const stream = new ReadableStream({
59
- start(controller) {
60
- controller.enqueue(new TextEncoder().encode(body));
61
- },
62
- });
63
- return new Response(stream, { headers: { "content-type": "text/event-stream" } });
64
- }
65
-
66
- describe("runAgentAsOwner", () => {
67
- test("mints an owner session, calls the agent, and returns once the result arrives", async () => {
68
- const calls: { url: string; auth: string | null; body: unknown }[] = [];
69
- const fetchImpl = async (url: string, init: RequestInit) => {
70
- const headers = init.headers as Record<string, string>;
71
- calls.push({ url, auth: headers.authorization ?? null, body: JSON.parse(String(init.body)) });
72
- if (url.endsWith("/api/auth/session")) return Response.json({ token: "owner-session", user_id: "u1" }, { status: 201 });
73
- return openStream(SSE_BODY);
74
- };
75
- const env = { APP_URL: "https://app.example.com", PYLON_ADMIN_TOKEN: "admin-token" };
76
- const out = await runAgentAsOwner(env, "u1", { input: "hi", title: "conv_1" }, fetchImpl);
77
- expect(out.ok && out.result.text).toBe("Hi there");
78
- expect(calls.map((c) => c.url)).toEqual([
79
- "https://app.example.com/api/auth/session",
80
- "https://app.example.com/api/fn/assistant",
81
- ]);
82
- expect(calls[0].auth).toBe("Bearer admin-token");
83
- expect(calls[0].body).toEqual({ user_id: "u1" });
84
- expect(calls[1].auth).toBe("Bearer owner-session");
85
- expect(calls[1].body).toEqual({ input: "hi", title: "conv_1" });
86
-
87
- // The session is reused on the next call.
88
- await runAgentAsOwner(env, "u1", { input: "again" }, fetchImpl);
89
- expect(calls.filter((c) => c.url.endsWith("/api/auth/session")).length).toBe(1);
90
- });
91
-
92
- test("re-mints once when the cached session is rejected", async () => {
93
- let mints = 0;
94
- let agentCalls = 0;
95
- const fetchImpl = async (url: string) => {
96
- if (url.endsWith("/api/auth/session")) return Response.json({ token: `s${++mints}` });
97
- agentCalls += 1;
98
- if (agentCalls === 1) return Response.json({ error: { code: "AUTH_REQUIRED", message: "expired" } }, { status: 401 });
99
- return openStream(SSE_BODY);
100
- };
101
- const out = await runAgentAsOwner({}, "u1", { input: "hi" }, fetchImpl);
102
- expect(out.ok).toBe(true);
103
- expect(mints).toBe(2);
104
- });
105
-
106
- test("reports a refused session mint", async () => {
107
- const fetchImpl = async () =>
108
- Response.json({ error: { code: "FORBIDDEN", message: "/api/auth/session requires admin auth" } }, { status: 403 });
109
- const out = await runAgentAsOwner({}, "u1", { input: "hi" }, fetchImpl);
110
- expect(out).toEqual({ ok: false, code: "OWNER_SESSION_FORBIDDEN", message: "/api/auth/session requires admin auth" });
111
- });
112
- });